misonL commited on
Commit
81587dd
·
verified ·
1 Parent(s): 97869a1

Deploy eefbbba to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@eefbbbad2b16424e315cc90a8e789882e4925175

public/hf-space-deploy-marker.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "schema_version": 1,
3
- "local_sha": "653775e99286817989ad7a4dfb56eedae605cdcb",
4
- "created_at": "2026-06-30T11:54:17.314Z",
5
- "deploy_id": "5a7966ca-fd3b-44a4-aea7-e7a5a4566be3"
6
  }
 
1
  {
2
  "schema_version": 1,
3
+ "local_sha": "eefbbbad2b16424e315cc90a8e789882e4925175",
4
+ "created_at": "2026-07-02T07:57:46.310Z",
5
+ "deploy_id": "cb1184cc-a24f-4024-a8e5-f65a2f020dd4"
6
  }
scripts/smoke-image-upstream-local-final-gate.test.mjs CHANGED
@@ -5,7 +5,8 @@ import { fileURLToPath } from 'node:url';
5
 
6
  const repoRoot = fileURLToPath(new URL('..', import.meta.url));
7
  const scriptPath = fileURLToPath(new URL('./smoke-image-upstream-local-final-gate.mjs', import.meta.url));
8
- const LOCAL_GATE_CASE_TIMEOUT_MS = '60000';
 
9
 
10
  describe('local image upstream final gate smoke launcher', () => {
11
  it('runs all independent upstream cases against the local fixture', () => {
 
5
 
6
  const repoRoot = fileURLToPath(new URL('..', import.meta.url));
7
  const scriptPath = fileURLToPath(new URL('./smoke-image-upstream-local-final-gate.mjs', import.meta.url));
8
+ // The fixture gate covers several independent upstream modes; leave room for bounded accepted-task retry paths.
9
+ const LOCAL_GATE_CASE_TIMEOUT_MS = '120000';
10
 
11
  describe('local image upstream final gate smoke launcher', () => {
12
  it('runs all independent upstream cases against the local fixture', () => {
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -16,7 +16,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
16
  - 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。
17
  - 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。
18
  - 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
19
- - 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。接入新上游渠道时,先确认 `/models` 和 `/images/generations` 能通,再用 `npm run smoke:image-upstream-real -- --allow-billable` 逐个验证 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`;如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就把该渠道的 `responses-non-stream`、`responses-sse` 从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。
20
  - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
21
  - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
22
  - 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
 
16
  - 批量 generate/edit:优先运行 `scripts/batch-images.mjs`,用 JSONL 输入和 append-only manifest 管理续跑。
17
  - 转换本地图片格式:优先运行 `scripts/convert-image-format.mjs`。
18
  - 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
19
+ - 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。接入新上游渠道时,先确认 `/models` 和 `/images/generations` 能通,再用 `npm run smoke:image-upstream-real -- --allow-billable` 逐个验证 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。如果某一路径先返回 `object=image.task,status=pending`,说明该请求方式不是直接完成结果应先确认同一业务键能否在同一渠道下重试拿到最终图片,再把可用的 `request_modes` 写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就把该渠道的 `responses-non-stream`、`responses-sse` 从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。
20
  - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
21
  - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
22
  - 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
src/app/api/agent/agent-routes.test.ts CHANGED
@@ -322,6 +322,47 @@ describe('Agent route integration', () => {
322
  }
323
  });
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  it('keeps Agent request diagnostics available when feedback lookup fails', async () => {
326
  const { generateImage, getAgentRequestDiagnostics } = await loadAgentRoutes();
327
  const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
@@ -3504,7 +3545,12 @@ function createPngWithDimensions(width: number, height: number): Buffer {
3504
  }
3505
 
3506
  async function startImageUpstream(
3507
- handler: (body: string, url: string) => unknown | Promise<unknown>
 
 
 
 
 
3508
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
3509
  const server = http.createServer(async (request, response) => {
3510
  if (
@@ -3519,7 +3565,7 @@ async function startImageUpstream(
3519
  request.on('data', (chunk: Buffer) => chunks.push(chunk));
3520
  await new Promise<void>((resolve) => request.on('end', resolve));
3521
  try {
3522
- const body = await handler(Buffer.concat(chunks).toString('utf8'), request.url || '');
3523
  response.writeHead(200, { 'Content-Type': 'application/json' });
3524
  response.end(JSON.stringify(body));
3525
  } catch (error) {
 
322
  }
323
  });
324
 
325
+ it('retries accepted async image tasks through Agent generate with the same upstream idempotency key', async () => {
326
+ const { generateImage } = await loadAgentRoutes();
327
+ const upstreamIdempotencyKeys: Array<string | undefined> = [];
328
+ let upstreamCalls = 0;
329
+ const upstream = await startImageUpstream((_body, _url, request, response) => {
330
+ upstreamCalls += 1;
331
+ const idempotencyKey = request.headers['idempotency-key'];
332
+ upstreamIdempotencyKeys.push(Array.isArray(idempotencyKey) ? idempotencyKey.join(',') : idempotencyKey);
333
+ if (upstreamCalls === 1) {
334
+ response.setHeader('Retry-After', '1');
335
+ return {
336
+ object: 'image.task',
337
+ status: 'pending',
338
+ task_id: 'agent-route-accepted-task',
339
+ poll_url: '/v1/image-tasks?ids=agent-route-accepted-task'
340
+ };
341
+ }
342
+ return { data: [{ b64_json: PNG_BASE64 }] };
343
+ });
344
+ process.env.OPENAI_API_KEY = 'test-key';
345
+ process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
346
+
347
+ try {
348
+ const response = await generateImage(
349
+ agentJsonRequest('agent-route-accepted-task-key', {
350
+ prompt: 'agent route accepted task',
351
+ response_mode: 'base64'
352
+ })
353
+ );
354
+
355
+ assert.equal(response.status, 200);
356
+ const body = await response.json();
357
+ assert.equal(body.cached, false);
358
+ assert.equal(body.images[0].b64_json, PNG_BASE64);
359
+ assert.deepEqual(upstreamIdempotencyKeys, ['agent-route-accepted-task-key', 'agent-route-accepted-task-key']);
360
+ assert.equal(upstreamCalls, 2);
361
+ } finally {
362
+ await upstream.close();
363
+ }
364
+ });
365
+
366
  it('keeps Agent request diagnostics available when feedback lookup fails', async () => {
367
  const { generateImage, getAgentRequestDiagnostics } = await loadAgentRoutes();
368
  const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
 
3545
  }
3546
 
3547
  async function startImageUpstream(
3548
+ handler: (
3549
+ body: string,
3550
+ url: string,
3551
+ request: http.IncomingMessage,
3552
+ response: http.ServerResponse
3553
+ ) => unknown | Promise<unknown>
3554
  ): Promise<{ baseUrl: string; close: () => Promise<void> }> {
3555
  const server = http.createServer(async (request, response) => {
3556
  if (
 
3565
  request.on('data', (chunk: Buffer) => chunks.push(chunk));
3566
  await new Promise<void>((resolve) => request.on('end', resolve));
3567
  try {
3568
+ const body = await handler(Buffer.concat(chunks).toString('utf8'), request.url || '', request, response);
3569
  response.writeHead(200, { 'Content-Type': 'application/json' });
3570
  response.end(JSON.stringify(body));
3571
  } catch (error) {
src/app/api/deploy-marker/route.ts CHANGED
@@ -1,6 +1,6 @@
1
  import { NextResponse } from 'next/server';
2
 
3
- const deployMarker = {"schema_version":1,"local_sha":"653775e99286817989ad7a4dfb56eedae605cdcb","created_at":"2026-06-30T11:54:17.314Z","deploy_id":"5a7966ca-fd3b-44a4-aea7-e7a5a4566be3"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
 
1
  import { NextResponse } from 'next/server';
2
 
3
+ const deployMarker = {"schema_version":1,"local_sha":"eefbbbad2b16424e315cc90a8e789882e4925175","created_at":"2026-07-02T07:57:46.310Z","deploy_id":"cb1184cc-a24f-4024-a8e5-f65a2f020dd4"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
src/app/api/images/route-test-helpers.ts CHANGED
@@ -205,6 +205,62 @@ export async function startImagesStreamFallbackUpstream(): Promise<{
205
  return { ...result, calls };
206
  }
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  export async function startHangingImagesStreamUpstream(): Promise<{
209
  baseUrl: string;
210
  calls: Array<{ stream?: boolean; partial_images?: number }>;
 
205
  return { ...result, calls };
206
  }
207
 
208
+ export async function startImagesAcceptedTaskStreamFallbackUpstream(): Promise<{
209
+ baseUrl: string;
210
+ calls: Array<{ stream?: boolean; partial_images?: number; idempotencyKey?: string | string[] }>;
211
+ close: () => Promise<void>;
212
+ }> {
213
+ const calls: Array<{ stream?: boolean; partial_images?: number; idempotencyKey?: string | string[] }> = [];
214
+ let nonStreamAttempts = 0;
215
+ const server = http.createServer(async (request, response) => {
216
+ if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
217
+ response.writeHead(404, { 'Content-Type': 'application/json' });
218
+ response.end(JSON.stringify({ error: { message: 'not found' } }));
219
+ return;
220
+ }
221
+ const chunks: Buffer[] = [];
222
+ request.on('data', (chunk: Buffer) => chunks.push(chunk));
223
+ await new Promise<void>((resolve) => request.on('end', resolve));
224
+ const payload = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {
225
+ stream?: boolean;
226
+ partial_images?: number;
227
+ };
228
+ calls.push({
229
+ stream: payload.stream,
230
+ partial_images: payload.partial_images,
231
+ idempotencyKey: request.headers['idempotency-key']
232
+ });
233
+ if (payload.stream) {
234
+ response.writeHead(200, { 'Content-Type': 'text/event-stream' });
235
+ response.write(
236
+ `event: image_generation.partial_image\ndata: ${JSON.stringify({
237
+ type: 'image_generation.partial_image',
238
+ b64_json: 'partial-before-accepted-task-fallback'
239
+ })}\n\n`
240
+ );
241
+ response.write('data: [DONE]\n\n');
242
+ response.end();
243
+ return;
244
+ }
245
+ nonStreamAttempts += 1;
246
+ response.writeHead(200, { 'Content-Type': 'application/json' });
247
+ if (nonStreamAttempts === 1) {
248
+ response.end(
249
+ JSON.stringify({
250
+ object: 'image.task',
251
+ status: 'pending',
252
+ task_id: 'fallback-task',
253
+ poll_url: '/api/image-tasks?ids=fallback-task'
254
+ })
255
+ );
256
+ return;
257
+ }
258
+ response.end(JSON.stringify({ data: [{ b64_json: PNG_BASE64 }] }));
259
+ });
260
+ const result = await listen(server);
261
+ return { ...result, calls };
262
+ }
263
+
264
  export async function startHangingImagesStreamUpstream(): Promise<{
265
  baseUrl: string;
266
  calls: Array<{ stream?: boolean; partial_images?: number }>;
src/app/api/images/route.test.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
  imageFormRequest,
4
  readSseEvents,
5
  startHangingImagesStreamUpstream,
 
6
  startImagesJsonUpstream,
7
  startImagesStreamFallbackUpstream,
8
  startResponsesImageUpstream,
@@ -127,9 +128,11 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
127
  it('uses a non-streaming channel request mode when page auto streaming has no SSE channel', async () => {
128
  const { POST } = await import('./route');
129
  const upstreamBodies: string[] = [];
 
130
  const upstream = await startImagesJsonUpstream(async (body, _url, request) => {
131
  if (request.method === 'POST') {
132
  upstreamBodies.push(body);
 
133
  }
134
  return { data: [{ b64_json: PNG_BASE64 }] };
135
  });
@@ -151,6 +154,95 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
151
  const upstreamJson = JSON.parse(upstreamBodies[0] || '{}') as Record<string, unknown>;
152
  assert.equal(upstreamJson.stream, false);
153
  assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
  } finally {
155
  await upstream.close();
156
  }
@@ -467,6 +559,45 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
467
  }
468
  });
469
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  it('does not mark auto streaming unavailable when the page SSE request is aborted', async () => {
471
  const { POST } = await import('./route');
472
  const { getServerChannelState } = await import('@/lib/server-channel-router');
@@ -1697,6 +1828,33 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
1697
  }
1698
  });
1699
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1700
  it('rejects invalid gpt-image-2 custom size boundaries before contacting upstream', async () => {
1701
  const { POST } = await import('./route');
1702
  let upstreamCalls = 0;
 
3
  imageFormRequest,
4
  readSseEvents,
5
  startHangingImagesStreamUpstream,
6
+ startImagesAcceptedTaskStreamFallbackUpstream,
7
  startImagesJsonUpstream,
8
  startImagesStreamFallbackUpstream,
9
  startResponsesImageUpstream,
 
128
  it('uses a non-streaming channel request mode when page auto streaming has no SSE channel', async () => {
129
  const { POST } = await import('./route');
130
  const upstreamBodies: string[] = [];
131
+ let observedIdempotencyKey: string | string[] | undefined;
132
  const upstream = await startImagesJsonUpstream(async (body, _url, request) => {
133
  if (request.method === 'POST') {
134
  upstreamBodies.push(body);
135
+ observedIdempotencyKey = request.headers['idempotency-key'];
136
  }
137
  return { data: [{ b64_json: PNG_BASE64 }] };
138
  });
 
154
  const upstreamJson = JSON.parse(upstreamBodies[0] || '{}') as Record<string, unknown>;
155
  assert.equal(upstreamJson.stream, false);
156
  assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
157
+ assert.equal(observedIdempotencyKey, 'client-route-stream');
158
+ } finally {
159
+ await upstream.close();
160
+ }
161
+ });
162
+
163
+ it('retries accepted async image tasks with the same upstream idempotency key', async () => {
164
+ const { POST } = await import('./route');
165
+ const observedIdempotencyKeys: Array<string | string[] | undefined> = [];
166
+ let upstreamCalls = 0;
167
+ const upstream = await startImagesJsonUpstream(async (_body, _url, request) => {
168
+ if (request.method !== 'POST') {
169
+ return { data: [{ b64_json: PNG_BASE64 }] };
170
+ }
171
+ upstreamCalls += 1;
172
+ observedIdempotencyKeys.push(request.headers['idempotency-key']);
173
+ if (upstreamCalls === 1) {
174
+ return {
175
+ object: 'image.task',
176
+ status: 'pending',
177
+ task_id: 'sync-gen-task',
178
+ poll_url: '/api/image-tasks?ids=sync-gen-task'
179
+ };
180
+ }
181
+ return { data: [{ b64_json: PNG_BASE64 }] };
182
+ });
183
+ process.env.OPENAI_CHANNEL_1_ID = 'json-task';
184
+ process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
185
+ process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key';
186
+ process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
187
+
188
+ try {
189
+ const response = await POST(
190
+ imageFormRequest({
191
+ streamMode: 'auto',
192
+ clientRequestId: 'accepted-task-retry-key'
193
+ })
194
+ );
195
+
196
+ assert.equal(response.status, 200);
197
+ assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
198
+ assert.equal(upstreamCalls, 2);
199
+ assert.deepEqual(observedIdempotencyKeys, ['accepted-task-retry-key', 'accepted-task-retry-key']);
200
+ } finally {
201
+ await upstream.close();
202
+ }
203
+ });
204
+
205
+ it('retries accepted async edit tasks with the same upstream idempotency key', async () => {
206
+ const { POST } = await import('./route');
207
+ const observedIdempotencyKeys: Array<string | string[] | undefined> = [];
208
+ let upstreamCalls = 0;
209
+ const upstream = await startImagesJsonUpstream(async (_body, _url, request) => {
210
+ if (request.method !== 'POST') {
211
+ return { data: [{ b64_json: PNG_BASE64 }] };
212
+ }
213
+ upstreamCalls += 1;
214
+ observedIdempotencyKeys.push(request.headers['idempotency-key']);
215
+ if (upstreamCalls === 1) {
216
+ return {
217
+ object: 'image.task',
218
+ status: 'pending',
219
+ task_id: 'sync-edit-task',
220
+ poll_url: '/api/image-tasks?ids=sync-edit-task'
221
+ };
222
+ }
223
+ return { data: [{ b64_json: PNG_BASE64 }] };
224
+ });
225
+ process.env.OPENAI_CHANNEL_1_ID = 'json-edit-task';
226
+ process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
227
+ process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key';
228
+ process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
229
+
230
+ try {
231
+ const response = await POST(
232
+ imageFormRequest({
233
+ mode: 'edit',
234
+ streamMode: 'auto',
235
+ clientRequestId: 'accepted-edit-task-retry-key'
236
+ })
237
+ );
238
+
239
+ assert.equal(response.status, 200);
240
+ assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
241
+ assert.equal(upstreamCalls, 2);
242
+ assert.deepEqual(observedIdempotencyKeys, [
243
+ 'accepted-edit-task-retry-key',
244
+ 'accepted-edit-task-retry-key'
245
+ ]);
246
  } finally {
247
  await upstream.close();
248
  }
 
559
  }
560
  });
561
 
562
+ it('retries accepted async image tasks after auto stream fallback with the same idempotency key', async () => {
563
+ const { POST } = await import('./route');
564
+ const upstream = await startImagesAcceptedTaskStreamFallbackUpstream();
565
+
566
+ try {
567
+ const response = await POST(
568
+ imageFormRequest({
569
+ apiBaseUrl: upstream.baseUrl,
570
+ apiKey: 'test-key',
571
+ streamMode: 'auto',
572
+ clientRequestId: 'client-route-auto-fallback-task'
573
+ })
574
+ );
575
+
576
+ assert.equal(response.status, 200);
577
+ assert.equal(response.headers.get('content-type'), 'text/event-stream');
578
+ const events = await readSseEvents(response);
579
+ assert.deepEqual(
580
+ events.map((event) => event.type),
581
+ ['partial_image', 'completed', 'done']
582
+ );
583
+ assert.equal(events[2].fallback_used, true);
584
+ assert.deepEqual(
585
+ upstream.calls.map((call) => call.stream),
586
+ [true, false, false]
587
+ );
588
+ assert.deepEqual(
589
+ upstream.calls.map((call) => call.idempotencyKey),
590
+ [
591
+ 'client-route-auto-fallback-task',
592
+ 'client-route-auto-fallback-task',
593
+ 'client-route-auto-fallback-task'
594
+ ]
595
+ );
596
+ } finally {
597
+ await upstream.close();
598
+ }
599
+ });
600
+
601
  it('does not mark auto streaming unavailable when the page SSE request is aborted', async () => {
602
  const { POST } = await import('./route');
603
  const { getServerChannelState } = await import('@/lib/server-channel-router');
 
1828
  }
1829
  });
1830
 
1831
+ it('rejects page SSE client request ids with control characters before contacting upstream', async () => {
1832
+ const { POST } = await import('./route');
1833
+ let upstreamCalls = 0;
1834
+ const upstream = await startImagesJsonUpstream(async () => {
1835
+ upstreamCalls += 1;
1836
+ return { data: [{ b64_json: PNG_BASE64 }] };
1837
+ });
1838
+
1839
+ try {
1840
+ const response = await POST(
1841
+ imageFormRequest({
1842
+ apiBaseUrl: upstream.baseUrl,
1843
+ apiKey: 'test-key',
1844
+ clientRequestId: 'bad\nrequest'
1845
+ })
1846
+ );
1847
+
1848
+ assert.equal(response.status, 400);
1849
+ const body = (await response.json()) as Record<string, unknown>;
1850
+ assert.match(String(body.error), /clientRequestId/);
1851
+ assert.match(String(body.error), /控制字符/);
1852
+ assert.equal(upstreamCalls, 0);
1853
+ } finally {
1854
+ await upstream.close();
1855
+ }
1856
+ });
1857
+
1858
  it('rejects invalid gpt-image-2 custom size boundaries before contacting upstream', async () => {
1859
  const { POST } = await import('./route');
1860
  let upstreamCalls = 0;
src/app/api/images/route.ts CHANGED
@@ -334,6 +334,7 @@ export async function POST(request: NextRequest) {
334
  }
335
  const formData = await request.formData();
336
  clientRequestId = readClientRequestId(formData);
 
337
  requestLogContext = clientRequestId ? { clientRequestId } : undefined;
338
  const requestApiKey = String(formData.get('apiKey') || '').trim();
339
  const requestApiBaseUrl = String(formData.get('apiBaseUrl') || '').trim();
@@ -513,9 +514,11 @@ export async function POST(request: NextRequest) {
513
  apiBaseUrl: effectiveApiBaseUrl,
514
  apiKey: effectiveApiKey,
515
  startedAtMs: upstreamStartedAtMs,
 
516
  clientRequestId,
517
  requestLogContext,
518
  selectedCredential,
 
519
  accessCookie,
520
  abortSignal: request.signal,
521
  streamFallbackEnabled: streamResolution.streamFallbackEnabled,
@@ -542,9 +545,11 @@ export async function POST(request: NextRequest) {
542
  apiBaseUrl: effectiveApiBaseUrl,
543
  apiKey: effectiveApiKey,
544
  startedAtMs: upstreamStartedAtMs,
 
545
  clientRequestId,
546
  requestLogContext,
547
  selectedCredential,
 
548
  accessCookie,
549
  abortSignal: request.signal,
550
  streamFallbackEnabled: streamResolution.streamFallbackEnabled,
 
334
  }
335
  const formData = await request.formData();
336
  clientRequestId = readClientRequestId(formData);
337
+ const upstreamIdempotencyKey = clientRequestId || crypto.randomUUID();
338
  requestLogContext = clientRequestId ? { clientRequestId } : undefined;
339
  const requestApiKey = String(formData.get('apiKey') || '').trim();
340
  const requestApiBaseUrl = String(formData.get('apiBaseUrl') || '').trim();
 
514
  apiBaseUrl: effectiveApiBaseUrl,
515
  apiKey: effectiveApiKey,
516
  startedAtMs: upstreamStartedAtMs,
517
+ upstreamIdempotencyKey,
518
  clientRequestId,
519
  requestLogContext,
520
  selectedCredential,
521
+ channelRequestMode: channelSelection.requestMode,
522
  accessCookie,
523
  abortSignal: request.signal,
524
  streamFallbackEnabled: streamResolution.streamFallbackEnabled,
 
545
  apiBaseUrl: effectiveApiBaseUrl,
546
  apiKey: effectiveApiKey,
547
  startedAtMs: upstreamStartedAtMs,
548
+ upstreamIdempotencyKey,
549
  clientRequestId,
550
  requestLogContext,
551
  selectedCredential,
552
+ channelRequestMode: channelSelection.requestMode,
553
  accessCookie,
554
  abortSignal: request.signal,
555
  streamFallbackEnabled: streamResolution.streamFallbackEnabled,
src/lib/accepted-image-task.ts ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type AcceptedImageTaskDetails = {
2
+ taskId?: string;
3
+ pollUrl?: string;
4
+ };
5
+
6
+ export function readAcceptedImageTaskDetails(result: unknown): AcceptedImageTaskDetails | undefined {
7
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return undefined;
8
+ const record = result as Record<string, unknown>;
9
+ if (readTrimmedString(record.object) !== 'image.task' || readTrimmedString(record.status) !== 'pending') {
10
+ return undefined;
11
+ }
12
+
13
+ const taskId = readTrimmedString(record.task_id);
14
+ const pollUrl = readTrimmedString(record.poll_url);
15
+ return {
16
+ ...(taskId ? { taskId } : {}),
17
+ ...(pollUrl ? { pollUrl } : {})
18
+ };
19
+ }
20
+
21
+ function readTrimmedString(value: unknown): string | undefined {
22
+ return typeof value === 'string' && value.trim() ? value.trim() : undefined;
23
+ }
src/lib/agent-image-service.test.ts CHANGED
@@ -1,4 +1,9 @@
1
- import { buildEditRequestHash, completeAgentExecutionState, hydrateAgentReplayResponse } from './agent-image-service';
 
 
 
 
 
2
  import type { AgentArtifactRecord, AgentStateStore } from './agent-state-store';
3
  import assert from 'node:assert/strict';
4
  import { describe, it } from 'node:test';
@@ -19,6 +24,21 @@ describe('buildEditRequestHash', () => {
19
  });
20
  });
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  describe('hydrateAgentReplayResponse', () => {
23
  it('rejects base64 replay when stored artifact filepath escapes the image directory', async () => {
24
  const store = createReplayStore([
 
1
+ import {
2
+ buildEditRequestHash,
3
+ completeAgentExecutionState,
4
+ hydrateAgentReplayResponse,
5
+ readIdempotencyKey
6
+ } from './agent-image-service';
7
  import type { AgentArtifactRecord, AgentStateStore } from './agent-state-store';
8
  import assert from 'node:assert/strict';
9
  import { describe, it } from 'node:test';
 
24
  });
25
  });
26
 
27
+ describe('readIdempotencyKey', () => {
28
+ it('rejects control characters before forwarding the value upstream', () => {
29
+ const headers = new Headers({ 'Idempotency-Key': `agent${String.fromCharCode(31)}key` });
30
+
31
+ assert.throws(
32
+ () => readIdempotencyKey(headers),
33
+ (error) => {
34
+ assert.ok(error instanceof Error);
35
+ assert.match(error.message, /控制字符/);
36
+ return true;
37
+ }
38
+ );
39
+ });
40
+ });
41
+
42
  describe('hydrateAgentReplayResponse', () => {
43
  it('rejects base64 replay when stored artifact filepath escapes the image directory', async () => {
44
  const store = createReplayStore([
src/lib/agent-image-service.ts CHANGED
@@ -68,6 +68,9 @@ import {
68
  import {
69
  InvalidOpenAiImagesResponseError,
70
  MissingOpenAiImageDataError,
 
 
 
71
  persistOpenAiImages as persistSharedOpenAiImages
72
  } from './image-service';
73
  import { collectOpenAiImagesFromStream } from './image-stream-collector';
@@ -184,6 +187,8 @@ const AGENT_EDIT_UNSUPPORTED_FIELDS = [
184
  'moderation'
185
  ] as const;
186
 
 
 
187
  export function readIdempotencyKey(headers: Headers): string {
188
  const value = headers.get('idempotency-key')?.trim();
189
  if (!value) {
@@ -203,6 +208,15 @@ export function readIdempotencyKey(headers: Headers): string {
203
  details: { fields: { 'Idempotency-Key': '长度不能超过 200 个字符' } }
204
  });
205
  }
 
 
 
 
 
 
 
 
 
206
  return value;
207
  }
208
 
@@ -315,13 +329,31 @@ export async function prepareAgentEdit(formData: FormData, headers: Headers): Pr
315
  return { credentialContext, prompt, model, n, size, quality, responseMode, streamRequest, imageFiles, maskFile };
316
  }
317
 
318
- function buildOpenAiRequestOptions(context: CredentialContext, abortSignal?: AbortSignal): OpenAI.RequestOptions {
 
 
 
319
  return buildOpenAIImageRequestOptions({
320
- abortSignal,
 
321
  headers: mergeUpstreamHeadersWithFixed(context.upstreamHeaders, {})
322
  });
323
  }
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  async function acquireAgentChannelCapacity(
326
  context: CredentialContext,
327
  abortSignal?: AbortSignal
@@ -395,7 +427,12 @@ export async function executeAgentGenerate(options: {
395
  let channelLease: ChannelCapacityLease | undefined;
396
  try {
397
  channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
398
- const result = await executeAgentGenerateUpstream(options.request, credentialContext, options.abortSignal);
 
 
 
 
 
399
  channelLease?.release();
400
  channelLease = undefined;
401
  return await persistOpenAiImages({
@@ -445,6 +482,7 @@ export async function executeAgentGenerate(options: {
445
  async function executeAgentGenerateUpstream(
446
  request: AgentGenerateRequest,
447
  credentialContext: CredentialContext,
 
448
  abortSignal?: AbortSignal
449
  ): Promise<OpenAI.Images.ImagesResponse> {
450
  const { openai } = credentialContext;
@@ -458,7 +496,7 @@ async function executeAgentGenerateUpstream(
458
  channelRequestMode: credentialContext.channelRequestMode
459
  };
460
  if (request.image_backend === 'responses-image-generation') {
461
- return executeAgentResponsesGenerate(request, credentialContext, abortSignal);
462
  }
463
  const baseParams = {
464
  model: request.model,
@@ -473,21 +511,34 @@ async function executeAgentGenerateUpstream(
473
  ...(request.force_web !== undefined ? { force_web: request.force_web } : {})
474
  };
475
  if (!shouldUseAgentUpstreamStream(streamOptions)) {
476
- return openai.images.generate(
477
- { ...baseParams, stream: false },
478
- buildOpenAiRequestOptions(credentialContext, abortSignal)
 
 
 
 
 
 
479
  );
480
  }
481
  const fallback = () =>
482
- openai.images.generate(
483
- { ...baseParams, stream: false },
484
- buildOpenAiRequestOptions(credentialContext, abortSignal)
 
 
 
 
 
 
485
  );
486
  try {
487
  const stream = await createImagesApiGenerateStream({
488
  apiBaseUrl: credentialContext.baseUrl,
489
  apiKey: credentialContext.apiKey,
490
  upstreamHeaders: credentialContext.upstreamHeaders,
 
491
  abortSignal,
492
  params: {
493
  ...baseParams,
@@ -544,6 +595,7 @@ function validateAgentGenerateAgainstUpstreamProfile(
544
  async function executeAgentResponsesGenerate(
545
  request: AgentGenerateRequest,
546
  credentialContext: CredentialContext,
 
547
  abortSignal?: AbortSignal
548
  ): Promise<OpenAI.Images.ImagesResponse> {
549
  const { openai } = credentialContext;
@@ -566,6 +618,7 @@ async function executeAgentResponsesGenerate(
566
  outputFormat: request.output_format,
567
  background: request.background,
568
  moderation: request.moderation,
 
569
  abortSignal,
570
  ...(request.output_compression !== undefined ? { outputCompression: request.output_compression } : {}),
571
  ...(request.promptOptimization !== undefined ? { promptOptimization: request.promptOptimization } : {}),
@@ -699,6 +752,8 @@ export async function executeAgentEdit(options: {
699
  try {
700
  const preparation = options.preparation ?? (await prepareAgentEdit(options.formData, options.headers));
701
  credentialContext = preparation.credentialContext;
 
 
702
  const editParams: OpenAI.Images.ImageEditParamsNonStreaming = {
703
  model: preparation.model,
704
  prompt: preparation.prompt,
@@ -714,20 +769,37 @@ export async function executeAgentEdit(options: {
714
  streamMode: preparation.streamRequest.streamMode,
715
  streamingStrategy: preparation.streamRequest.streamingStrategy,
716
  partialImages: preparation.streamRequest.partialImages,
717
- selectedCredential: credentialContext.selectedCredential,
718
- channelRequestMode: credentialContext.channelRequestMode
719
  };
720
- channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
721
  const result = shouldUseAgentUpstreamStream(streamOptions)
722
  ? await executeAgentEditStream({
723
- credentialContext,
724
  params: editParams,
725
  streamOptions,
 
726
  abortSignal: options.abortSignal
727
  })
728
- : await credentialContext.openai.images.edit(
729
- editParams,
730
- buildOpenAiRequestOptions(credentialContext, options.abortSignal)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
731
  );
732
  channelLease?.release();
733
  channelLease = undefined;
@@ -742,9 +814,9 @@ export async function executeAgentEdit(options: {
742
  requestId: options.requestId,
743
  idempotencyKey: options.idempotencyKey,
744
  cached: options.cached,
745
- apiBaseUrl: credentialContext.baseUrl,
746
- apiKey: credentialContext.apiKey,
747
- upstreamHeaders: credentialContext.upstreamHeaders,
748
  execution: {
749
  startedAtMs,
750
  startedAt,
@@ -757,10 +829,10 @@ export async function executeAgentEdit(options: {
757
  imageBackend: 'images-api',
758
  streamMode: preparation.streamRequest.streamMode,
759
  streamingStrategy: preparation.streamRequest.streamingStrategy,
760
- channelRequestMode: credentialContext.channelRequestMode,
761
- channelRequestModeFallbackApplied: credentialContext.channelRequestModeFallbackApplied,
762
- channelRequestModeDecision: credentialContext.channelRequestModeDecision,
763
- selectedCredential: credentialContext.selectedCredential
764
  },
765
  abortSignal: options.abortSignal
766
  });
@@ -780,10 +852,21 @@ async function executeAgentEditStream(input: {
780
  credentialContext: CredentialContext;
781
  params: OpenAI.Images.ImageEditParamsNonStreaming;
782
  streamOptions: AgentStreamOptions;
 
783
  abortSignal?: AbortSignal;
784
  }): Promise<OpenAI.Images.ImagesResponse> {
785
- const requestOptions = buildOpenAiRequestOptions(input.credentialContext, input.abortSignal);
786
- const fallback = () => input.credentialContext.openai.images.edit(input.params, requestOptions);
 
 
 
 
 
 
 
 
 
 
787
  try {
788
  const stream = await input.credentialContext.openai.images.edit(
789
  {
@@ -1145,16 +1228,20 @@ async function persistOpenAiImages(options: {
1145
  if (!(error instanceof InvalidOpenAiImagesResponseError) && !(error instanceof MissingOpenAiImageDataError)) {
1146
  throw error;
1147
  }
 
 
1148
  throw new AgentApiError({
1149
  code: 'upstream_unavailable',
1150
  message:
1151
  error instanceof MissingOpenAiImageDataError
1152
  ? `索引 ${error.index} 的图片数据缺少 base64 数据。`
1153
- : '上游返回了空的 Images 响应。',
 
 
1154
  status: 502,
1155
- retryable: true,
1156
  upstreamStatus: 502,
1157
- retryAfterSeconds: 15
1158
  });
1159
  }
1160
 
 
68
  import {
69
  InvalidOpenAiImagesResponseError,
70
  MissingOpenAiImageDataError,
71
+ readAcceptedImageTaskDetails,
72
+ resolveAcceptedImageTaskResponse,
73
+ type AcceptedImageTaskResponseError,
74
  persistOpenAiImages as persistSharedOpenAiImages
75
  } from './image-service';
76
  import { collectOpenAiImagesFromStream } from './image-stream-collector';
 
187
  'moderation'
188
  ] as const;
189
 
190
+ const HTTP_HEADER_VALUE_CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/;
191
+
192
  export function readIdempotencyKey(headers: Headers): string {
193
  const value = headers.get('idempotency-key')?.trim();
194
  if (!value) {
 
208
  details: { fields: { 'Idempotency-Key': '长度不能超过 200 个字符' } }
209
  });
210
  }
211
+ if (HTTP_HEADER_VALUE_CONTROL_CHAR_PATTERN.test(value)) {
212
+ throw new AgentApiError({
213
+ code: 'validation_error',
214
+ message: 'Idempotency-Key 不能包含控制字符。',
215
+ status: 422,
216
+ retryable: false,
217
+ details: { fields: { 'Idempotency-Key': '不能包含控制字符' } }
218
+ });
219
+ }
220
  return value;
221
  }
222
 
 
329
  return { credentialContext, prompt, model, n, size, quality, responseMode, streamRequest, imageFiles, maskFile };
330
  }
331
 
332
+ function buildOpenAiRequestOptions(
333
+ context: CredentialContext,
334
+ input: { abortSignal?: AbortSignal; idempotencyKey: string }
335
+ ): OpenAI.RequestOptions {
336
  return buildOpenAIImageRequestOptions({
337
+ abortSignal: input.abortSignal,
338
+ idempotencyKey: input.idempotencyKey,
339
  headers: mergeUpstreamHeadersWithFixed(context.upstreamHeaders, {})
340
  });
341
  }
342
 
343
+ function onAgentAcceptedImageTask(context: CredentialContext, modeLabel: string, idempotencyKey: string) {
344
+ return (details: AcceptedImageTaskResponseError, attempt: number, delayMs: number) => {
345
+ appLogger.warn(`Agent ${modeLabel} 上游返回异步图片任务,等待后重试同步结果。`, {
346
+ idempotencyKey,
347
+ channelId: context.selectedCredential?.channelId,
348
+ requestMode: context.channelRequestMode,
349
+ attempt,
350
+ delayMs,
351
+ taskId: details.taskId,
352
+ hasPollUrl: Boolean(details.pollUrl)
353
+ });
354
+ };
355
+ }
356
+
357
  async function acquireAgentChannelCapacity(
358
  context: CredentialContext,
359
  abortSignal?: AbortSignal
 
427
  let channelLease: ChannelCapacityLease | undefined;
428
  try {
429
  channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
430
+ const result = await executeAgentGenerateUpstream(
431
+ options.request,
432
+ credentialContext,
433
+ options.idempotencyKey,
434
+ options.abortSignal
435
+ );
436
  channelLease?.release();
437
  channelLease = undefined;
438
  return await persistOpenAiImages({
 
482
  async function executeAgentGenerateUpstream(
483
  request: AgentGenerateRequest,
484
  credentialContext: CredentialContext,
485
+ idempotencyKey: string,
486
  abortSignal?: AbortSignal
487
  ): Promise<OpenAI.Images.ImagesResponse> {
488
  const { openai } = credentialContext;
 
496
  channelRequestMode: credentialContext.channelRequestMode
497
  };
498
  if (request.image_backend === 'responses-image-generation') {
499
+ return executeAgentResponsesGenerate(request, credentialContext, idempotencyKey, abortSignal);
500
  }
501
  const baseParams = {
502
  model: request.model,
 
511
  ...(request.force_web !== undefined ? { force_web: request.force_web } : {})
512
  };
513
  if (!shouldUseAgentUpstreamStream(streamOptions)) {
514
+ return resolveAcceptedImageTaskResponse(
515
+ () =>
516
+ openai.images
517
+ .generate(
518
+ { ...baseParams, stream: false },
519
+ buildOpenAiRequestOptions(credentialContext, { abortSignal, idempotencyKey })
520
+ )
521
+ .withResponse(),
522
+ { abortSignal, onAcceptedTask: onAgentAcceptedImageTask(credentialContext, 'generate', idempotencyKey) }
523
  );
524
  }
525
  const fallback = () =>
526
+ resolveAcceptedImageTaskResponse(
527
+ () =>
528
+ openai.images
529
+ .generate(
530
+ { ...baseParams, stream: false },
531
+ buildOpenAiRequestOptions(credentialContext, { abortSignal, idempotencyKey })
532
+ )
533
+ .withResponse(),
534
+ { abortSignal, onAcceptedTask: onAgentAcceptedImageTask(credentialContext, 'generate', idempotencyKey) }
535
  );
536
  try {
537
  const stream = await createImagesApiGenerateStream({
538
  apiBaseUrl: credentialContext.baseUrl,
539
  apiKey: credentialContext.apiKey,
540
  upstreamHeaders: credentialContext.upstreamHeaders,
541
+ idempotencyKey,
542
  abortSignal,
543
  params: {
544
  ...baseParams,
 
595
  async function executeAgentResponsesGenerate(
596
  request: AgentGenerateRequest,
597
  credentialContext: CredentialContext,
598
+ idempotencyKey: string,
599
  abortSignal?: AbortSignal
600
  ): Promise<OpenAI.Images.ImagesResponse> {
601
  const { openai } = credentialContext;
 
618
  outputFormat: request.output_format,
619
  background: request.background,
620
  moderation: request.moderation,
621
+ idempotencyKey,
622
  abortSignal,
623
  ...(request.output_compression !== undefined ? { outputCompression: request.output_compression } : {}),
624
  ...(request.promptOptimization !== undefined ? { promptOptimization: request.promptOptimization } : {}),
 
752
  try {
753
  const preparation = options.preparation ?? (await prepareAgentEdit(options.formData, options.headers));
754
  credentialContext = preparation.credentialContext;
755
+ // Keep a const alias so TypeScript preserves the narrowed credential context below.
756
+ const activeCredentialContext = credentialContext;
757
  const editParams: OpenAI.Images.ImageEditParamsNonStreaming = {
758
  model: preparation.model,
759
  prompt: preparation.prompt,
 
769
  streamMode: preparation.streamRequest.streamMode,
770
  streamingStrategy: preparation.streamRequest.streamingStrategy,
771
  partialImages: preparation.streamRequest.partialImages,
772
+ selectedCredential: activeCredentialContext.selectedCredential,
773
+ channelRequestMode: activeCredentialContext.channelRequestMode
774
  };
775
+ channelLease = await acquireAgentChannelCapacity(activeCredentialContext, options.abortSignal);
776
  const result = shouldUseAgentUpstreamStream(streamOptions)
777
  ? await executeAgentEditStream({
778
+ credentialContext: activeCredentialContext,
779
  params: editParams,
780
  streamOptions,
781
+ idempotencyKey: options.idempotencyKey,
782
  abortSignal: options.abortSignal
783
  })
784
+ : await resolveAcceptedImageTaskResponse(
785
+ () =>
786
+ activeCredentialContext.openai.images
787
+ .edit(
788
+ editParams,
789
+ buildOpenAiRequestOptions(activeCredentialContext, {
790
+ abortSignal: options.abortSignal,
791
+ idempotencyKey: options.idempotencyKey
792
+ })
793
+ )
794
+ .withResponse(),
795
+ {
796
+ abortSignal: options.abortSignal,
797
+ onAcceptedTask: onAgentAcceptedImageTask(
798
+ activeCredentialContext,
799
+ 'edit',
800
+ options.idempotencyKey
801
+ )
802
+ }
803
  );
804
  channelLease?.release();
805
  channelLease = undefined;
 
814
  requestId: options.requestId,
815
  idempotencyKey: options.idempotencyKey,
816
  cached: options.cached,
817
+ apiBaseUrl: activeCredentialContext.baseUrl,
818
+ apiKey: activeCredentialContext.apiKey,
819
+ upstreamHeaders: activeCredentialContext.upstreamHeaders,
820
  execution: {
821
  startedAtMs,
822
  startedAt,
 
829
  imageBackend: 'images-api',
830
  streamMode: preparation.streamRequest.streamMode,
831
  streamingStrategy: preparation.streamRequest.streamingStrategy,
832
+ channelRequestMode: activeCredentialContext.channelRequestMode,
833
+ channelRequestModeFallbackApplied: activeCredentialContext.channelRequestModeFallbackApplied,
834
+ channelRequestModeDecision: activeCredentialContext.channelRequestModeDecision,
835
+ selectedCredential: activeCredentialContext.selectedCredential
836
  },
837
  abortSignal: options.abortSignal
838
  });
 
852
  credentialContext: CredentialContext;
853
  params: OpenAI.Images.ImageEditParamsNonStreaming;
854
  streamOptions: AgentStreamOptions;
855
+ idempotencyKey: string;
856
  abortSignal?: AbortSignal;
857
  }): Promise<OpenAI.Images.ImagesResponse> {
858
+ const requestOptions = buildOpenAiRequestOptions(input.credentialContext, {
859
+ abortSignal: input.abortSignal,
860
+ idempotencyKey: input.idempotencyKey
861
+ });
862
+ const fallback = () =>
863
+ resolveAcceptedImageTaskResponse(
864
+ () => input.credentialContext.openai.images.edit(input.params, requestOptions).withResponse(),
865
+ {
866
+ abortSignal: input.abortSignal,
867
+ onAcceptedTask: onAgentAcceptedImageTask(input.credentialContext, 'edit', input.idempotencyKey)
868
+ }
869
+ );
870
  try {
871
  const stream = await input.credentialContext.openai.images.edit(
872
  {
 
1228
  if (!(error instanceof InvalidOpenAiImagesResponseError) && !(error instanceof MissingOpenAiImageDataError)) {
1229
  throw error;
1230
  }
1231
+ const acceptedTask =
1232
+ error instanceof InvalidOpenAiImagesResponseError ? readAcceptedImageTaskDetails(error.result) : undefined;
1233
  throw new AgentApiError({
1234
  code: 'upstream_unavailable',
1235
  message:
1236
  error instanceof MissingOpenAiImageDataError
1237
  ? `索引 ${error.index} 的图片数据缺少 base64 数据。`
1238
+ : acceptedTask
1239
+ ? '上游返回了异步图片任务元数据,而不是最终图片结果;当前渠道不兼容该请求方式。'
1240
+ : '上游返回了空的 Images 响应。',
1241
  status: 502,
1242
+ retryable: !acceptedTask,
1243
  upstreamStatus: 502,
1244
+ ...(acceptedTask ? {} : { retryAfterSeconds: 15 })
1245
  });
1246
  }
1247
 
src/lib/api-error-response.test.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
  } from './api-error-response';
9
  import { type ChannelRequestMode } from './channel-request-mode';
10
  import { RequestValidationError } from './image-request-utils';
 
11
  import assert from 'node:assert/strict';
12
  import { describe, it } from 'node:test';
13
 
@@ -114,6 +115,48 @@ describe('normalizeAgentError', () => {
114
  ).diagnostics?.transport_error_kind,
115
  'sse_final_missing'
116
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  });
118
 
119
  it('adds sanitized upstream diagnostics without inventing an HTTP status', () => {
 
8
  } from './api-error-response';
9
  import { type ChannelRequestMode } from './channel-request-mode';
10
  import { RequestValidationError } from './image-request-utils';
11
+ import { AcceptedImageTaskResponseError } from './image-service';
12
  import assert from 'node:assert/strict';
13
  import { describe, it } from 'node:test';
14
 
 
115
  ).diagnostics?.transport_error_kind,
116
  'sse_final_missing'
117
  );
118
+ assert.equal(
119
+ normalizeAgentError(
120
+ Object.assign(new Error('上游返回了异步图片任务,但当前服务不支持该任务态的自动轮询。'), {
121
+ name: 'AcceptedImageTaskStreamResultError',
122
+ status: 502
123
+ })
124
+ ).diagnostics?.transport_error_kind,
125
+ 'upstream_timeout'
126
+ );
127
+ assert.equal(
128
+ normalizeAgentError(new AcceptedImageTaskResponseError({ taskId: 'accepted-non-stream-task' }))
129
+ .diagnostics?.transport_error_kind,
130
+ 'upstream_timeout'
131
+ );
132
+ });
133
+
134
+ it('maps accepted image task exhaustion to non-retryable upstream errors', () => {
135
+ const error = normalizeAgentError(new AcceptedImageTaskResponseError({ taskId: 'accepted-non-stream-task' }));
136
+
137
+ assert.equal(error.code, 'upstream_unavailable');
138
+ assert.equal(error.status, 502);
139
+ assert.equal(error.retryable, false);
140
+ assert.equal(error.retryAfterSeconds, undefined);
141
+ assert.equal(error.upstreamStatus, 502);
142
+ assert.equal(error.diagnostics?.transport_error_kind, 'upstream_timeout');
143
+ assert.equal(error.diagnostics?.retry_after_seconds, undefined);
144
+ });
145
+
146
+ it('keeps generic upstream failures mentioning async image tasks retryable', () => {
147
+ const error = normalizeAgentError(
148
+ Object.assign(new Error('代理层异步图片任务日志写入失败'), {
149
+ status: 502
150
+ })
151
+ );
152
+
153
+ assert.equal(error.code, 'upstream_unavailable');
154
+ assert.equal(error.status, 502);
155
+ assert.equal(error.retryable, true);
156
+ assert.equal(error.retryAfterSeconds, 15);
157
+ assert.equal(error.upstreamStatus, 502);
158
+ assert.equal(error.diagnostics?.transport_error_kind, undefined);
159
+ assert.equal(error.diagnostics?.retry_after_seconds, 15);
160
  });
161
 
162
  it('adds sanitized upstream diagnostics without inventing an HTTP status', () => {
src/lib/api-error-response.ts CHANGED
@@ -363,6 +363,9 @@ function classifyTransportErrorKind(error: unknown): AgentTransportErrorKind | u
363
  ) {
364
  return 'sse_final_missing';
365
  }
 
 
 
366
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns';
367
  if (
368
  code === 'CERT_HAS_EXPIRED' ||
@@ -477,6 +480,19 @@ export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagn
477
 
478
  const status = readNumberField(error, 'status') ?? readNumberField(error, 'statusCode');
479
  const message = error instanceof Error ? error.message : (readStringField(error, 'message') ?? '发生未知错误。');
 
 
 
 
 
 
 
 
 
 
 
 
 
480
  if (isChannelRequestModeFailure(error, diagnostics.channel_request_mode)) {
481
  const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15;
482
  return new AgentApiError({
@@ -596,3 +612,8 @@ export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagn
596
  diagnostics: buildDiagnostics(error, diagnostics)
597
  });
598
  }
 
 
 
 
 
 
363
  ) {
364
  return 'sse_final_missing';
365
  }
366
+ if (isAcceptedImageTaskError(error)) {
367
+ return 'upstream_timeout';
368
+ }
369
  if (code === 'ENOTFOUND' || code === 'EAI_AGAIN') return 'dns';
370
  if (
371
  code === 'CERT_HAS_EXPIRED' ||
 
480
 
481
  const status = readNumberField(error, 'status') ?? readNumberField(error, 'statusCode');
482
  const message = error instanceof Error ? error.message : (readStringField(error, 'message') ?? '发生未知错误。');
483
+ if (isAcceptedImageTaskError(error)) {
484
+ return new AgentApiError({
485
+ code: 'upstream_unavailable',
486
+ message,
487
+ status: 502,
488
+ retryable: false,
489
+ upstreamStatus: status,
490
+ diagnostics: buildDiagnostics(error, {
491
+ ...diagnostics,
492
+ ...(status !== undefined ? { upstreamStatus: status } : {})
493
+ })
494
+ });
495
+ }
496
  if (isChannelRequestModeFailure(error, diagnostics.channel_request_mode)) {
497
  const retryAfterSeconds = readRetryAfterSeconds(error) ?? 15;
498
  return new AgentApiError({
 
612
  diagnostics: buildDiagnostics(error, diagnostics)
613
  });
614
  }
615
+
616
+ function isAcceptedImageTaskError(error: unknown): boolean {
617
+ const name = readStringField(error, 'name') || readConstructorName(error);
618
+ return name === 'AcceptedImageTaskResponseError' || name === 'AcceptedImageTaskStreamResultError';
619
+ }
src/lib/image-route-mode-handlers.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { appLogger } from './app-logger';
 
2
  import type { ChannelCredential } from './channel-router';
3
  import {
4
  RequestValidationError,
@@ -22,6 +23,10 @@ import {
22
  type StorageMode,
23
  type ValidOutputFormat
24
  } from './image-request-utils';
 
 
 
 
25
  import {
26
  appendAccessCookie,
27
  reportServerCredentialFailure,
@@ -61,9 +66,11 @@ type CommonModeInput = {
61
  apiBaseUrl?: string;
62
  apiKey: string;
63
  startedAtMs: number;
 
64
  clientRequestId?: string;
65
  requestLogContext?: RequestLogContext;
66
  selectedCredential?: ChannelCredential;
 
67
  accessCookie?: AccessCookie;
68
  abortSignal?: AbortSignal;
69
  streamFallbackEnabled?: boolean;
@@ -214,10 +221,26 @@ function readResponsesImageExtensions(
214
  function openAiRequestOptions(input: CommonModeInput): OpenAI.RequestOptions {
215
  return buildOpenAIImageRequestOptions({
216
  abortSignal: input.abortSignal,
 
217
  headers: mergeUpstreamHeadersWithFixed(input.upstreamHeaders, {})
218
  });
219
  }
220
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  async function createResponsesImageResult(input: CommonModeInput, options: GenerateOptions): Promise<ImageModeResult> {
222
  if (options.n !== 1) {
223
  throw new RequestValidationError('Responses API 图片后端当前只支持单张生成。', 400);
@@ -235,6 +258,7 @@ async function createResponsesImageResult(input: CommonModeInput, options: Gener
235
  outputFormat: options.outputFormat,
236
  background: options.background,
237
  moderation: options.moderation,
 
238
  abortSignal: input.abortSignal,
239
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
240
  ...readResponsesImageExtensions(input.formData)
@@ -259,6 +283,7 @@ async function createResponsesImageResultOnly(
259
  outputFormat: options.outputFormat,
260
  background: options.background,
261
  moderation: options.moderation,
 
262
  abortSignal: input.abortSignal,
263
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
264
  ...readResponsesImageExtensions(input.formData)
@@ -282,6 +307,7 @@ async function createResponsesImageStreamResponse(
282
  outputFormat: options.outputFormat,
283
  background: options.background,
284
  moderation: options.moderation,
 
285
  partialImagesCount: toResponsesPartialImagesCount(input.partialImagesCount),
286
  abortSignal: input.abortSignal,
287
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
@@ -324,7 +350,10 @@ async function createImagesGenerateResultOnly(
324
  const params: OpenAI.Images.ImageGenerateParamsNonStreaming = { ...options.baseParams, stream: false };
325
  appLogger.info('调用 OpenAI generate。', input.requestLogContext);
326
  appLogger.debug('调用 OpenAI generate,参数:', { ...params, ...input.requestLogContext });
327
- return input.openai.images.generate(params, openAiRequestOptions(input));
 
 
 
328
  }
329
 
330
  async function createGenerateStreamResponse(
@@ -342,6 +371,7 @@ async function createGenerateStreamResponse(
342
  apiBaseUrl: input.apiBaseUrl,
343
  apiKey: input.apiKey,
344
  upstreamHeaders: input.upstreamHeaders,
 
345
  abortSignal: input.abortSignal,
346
  params: streamParams
347
  });
@@ -461,7 +491,10 @@ async function createEditResultOnly(
461
  };
462
  appLogger.info('调用 OpenAI edit。', input.requestLogContext);
463
  logEditParams(input, options, params);
464
- return input.openai.images.edit(params, openAiRequestOptions(input));
 
 
 
465
  }
466
 
467
  async function createEditStreamResponse(input: CommonModeInput, options: EditOptions): Promise<ImageModeResult> {
@@ -531,6 +564,7 @@ export async function handleEditImageMode(
531
  outputFormat: options.outputFormat,
532
  background: 'auto' as const,
533
  moderation: options.moderation,
 
534
  abortSignal: input.abortSignal,
535
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
536
  ...readResponsesImageExtensions(input.formData)
 
1
  import { appLogger } from './app-logger';
2
+ import type { ChannelRequestMode } from './channel-request-mode';
3
  import type { ChannelCredential } from './channel-router';
4
  import {
5
  RequestValidationError,
 
23
  type StorageMode,
24
  type ValidOutputFormat
25
  } from './image-request-utils';
26
+ import {
27
+ resolveAcceptedImageTaskResponse,
28
+ type AcceptedImageTaskResponseError
29
+ } from './image-service';
30
  import {
31
  appendAccessCookie,
32
  reportServerCredentialFailure,
 
66
  apiBaseUrl?: string;
67
  apiKey: string;
68
  startedAtMs: number;
69
+ upstreamIdempotencyKey?: string;
70
  clientRequestId?: string;
71
  requestLogContext?: RequestLogContext;
72
  selectedCredential?: ChannelCredential;
73
+ channelRequestMode?: ChannelRequestMode;
74
  accessCookie?: AccessCookie;
75
  abortSignal?: AbortSignal;
76
  streamFallbackEnabled?: boolean;
 
221
  function openAiRequestOptions(input: CommonModeInput): OpenAI.RequestOptions {
222
  return buildOpenAIImageRequestOptions({
223
  abortSignal: input.abortSignal,
224
+ idempotencyKey: input.upstreamIdempotencyKey,
225
  headers: mergeUpstreamHeadersWithFixed(input.upstreamHeaders, {})
226
  });
227
  }
228
 
229
+ function onAcceptedImageTask(input: CommonModeInput, modeLabel: string) {
230
+ return (details: AcceptedImageTaskResponseError, attempt: number, delayMs: number) => {
231
+ appLogger.warn(`上游 ${modeLabel} 返回异步图片任务,等待后重试同步结果。`, {
232
+ ...input.requestLogContext,
233
+ idempotencyKey: input.upstreamIdempotencyKey,
234
+ channelId: input.selectedCredential?.channelId,
235
+ requestMode: input.channelRequestMode,
236
+ attempt,
237
+ delayMs,
238
+ taskId: details.taskId,
239
+ hasPollUrl: Boolean(details.pollUrl)
240
+ });
241
+ };
242
+ }
243
+
244
  async function createResponsesImageResult(input: CommonModeInput, options: GenerateOptions): Promise<ImageModeResult> {
245
  if (options.n !== 1) {
246
  throw new RequestValidationError('Responses API 图片后端当前只支持单张生成。', 400);
 
258
  outputFormat: options.outputFormat,
259
  background: options.background,
260
  moderation: options.moderation,
261
+ idempotencyKey: input.upstreamIdempotencyKey,
262
  abortSignal: input.abortSignal,
263
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
264
  ...readResponsesImageExtensions(input.formData)
 
283
  outputFormat: options.outputFormat,
284
  background: options.background,
285
  moderation: options.moderation,
286
+ idempotencyKey: input.upstreamIdempotencyKey,
287
  abortSignal: input.abortSignal,
288
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
289
  ...readResponsesImageExtensions(input.formData)
 
307
  outputFormat: options.outputFormat,
308
  background: options.background,
309
  moderation: options.moderation,
310
+ idempotencyKey: input.upstreamIdempotencyKey,
311
  partialImagesCount: toResponsesPartialImagesCount(input.partialImagesCount),
312
  abortSignal: input.abortSignal,
313
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
 
350
  const params: OpenAI.Images.ImageGenerateParamsNonStreaming = { ...options.baseParams, stream: false };
351
  appLogger.info('调用 OpenAI generate。', input.requestLogContext);
352
  appLogger.debug('调用 OpenAI generate,参数:', { ...params, ...input.requestLogContext });
353
+ return resolveAcceptedImageTaskResponse(
354
+ () => input.openai.images.generate(params, openAiRequestOptions(input)).withResponse(),
355
+ { abortSignal: input.abortSignal, onAcceptedTask: onAcceptedImageTask(input, 'generate') }
356
+ );
357
  }
358
 
359
  async function createGenerateStreamResponse(
 
371
  apiBaseUrl: input.apiBaseUrl,
372
  apiKey: input.apiKey,
373
  upstreamHeaders: input.upstreamHeaders,
374
+ idempotencyKey: input.upstreamIdempotencyKey,
375
  abortSignal: input.abortSignal,
376
  params: streamParams
377
  });
 
491
  };
492
  appLogger.info('调用 OpenAI edit。', input.requestLogContext);
493
  logEditParams(input, options, params);
494
+ return resolveAcceptedImageTaskResponse(
495
+ () => input.openai.images.edit(params, openAiRequestOptions(input)).withResponse(),
496
+ { abortSignal: input.abortSignal, onAcceptedTask: onAcceptedImageTask(input, 'edit') }
497
+ );
498
  }
499
 
500
  async function createEditStreamResponse(input: CommonModeInput, options: EditOptions): Promise<ImageModeResult> {
 
564
  outputFormat: options.outputFormat,
565
  background: 'auto' as const,
566
  moderation: options.moderation,
567
+ idempotencyKey: input.upstreamIdempotencyKey,
568
  abortSignal: input.abortSignal,
569
  ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}),
570
  ...readResponsesImageExtensions(input.formData)
src/lib/image-route-support.test.ts ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { describeInvalidImagesResponse, readClientRequestId } from './image-route-support';
2
+ import assert from 'node:assert/strict';
3
+ import { describe, it } from 'node:test';
4
+
5
+ function formDataWithClientRequestId(value: string): FormData {
6
+ const formData = new FormData();
7
+ formData.append('clientRequestId', value);
8
+ return formData;
9
+ }
10
+
11
+ describe('describeInvalidImagesResponse', () => {
12
+ it('explains accepted async image tasks explicitly', () => {
13
+ const message = describeInvalidImagesResponse({
14
+ object: 'image.task',
15
+ status: 'pending',
16
+ task_id: 'sync-gen-task',
17
+ poll_url: '/api/image-tasks?ids=sync-gen-task&signature=secret'
18
+ });
19
+
20
+ assert.match(message, /同一业务幂等键有界重试后仍拿不到最终图片/);
21
+ assert.match(message, /poll_url=present/);
22
+ assert.equal(message.includes('signature=secret'), false);
23
+ });
24
+
25
+ it('keeps the generic explanation for ordinary invalid Images responses', () => {
26
+ assert.match(describeInvalidImagesResponse({}), /不是 OpenAI Images 格式/);
27
+ });
28
+ });
29
+
30
+ describe('readClientRequestId', () => {
31
+ it('rejects all HTTP header control characters', () => {
32
+ for (const value of ['bad\u0000request', 'bad\u007frequest', 'bad\trequest']) {
33
+ assert.throws(() => readClientRequestId(formDataWithClientRequestId(value)), /控制字符/);
34
+ }
35
+ });
36
+
37
+ it('treats trimmed full-width whitespace as an empty request id', () => {
38
+ assert.equal(readClientRequestId(formDataWithClientRequestId('\u3000')), undefined);
39
+ });
40
+ });
src/lib/image-route-support.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
  isCredentialFailure
10
  } from './channel-router';
11
  import { RequestValidationError } from './image-request-utils';
 
12
  import type { ImageGenerationBackend } from './image-upstream-strategy';
13
  import { getServerChannelState } from './server-channel-router';
14
  import { buildAccessCookie, outputDir, readBooleanEnv, serializeAccessCookie } from './server-runtime';
@@ -20,6 +21,8 @@ export type AccessCookie = ReturnType<typeof buildAccessCookie>;
20
  export type ImageBackend = ImageGenerationBackend;
21
  export type RequestLogContext = { clientRequestId: string };
22
 
 
 
23
  export function readClientRequestId(formData: FormData): string | undefined {
24
  const value = formData.get('clientRequestId');
25
  if (typeof value !== 'string') return undefined;
@@ -30,6 +33,9 @@ export function readClientRequestId(formData: FormData): string | undefined {
30
  `clientRequestId 长度不能超过 ${PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH} 个字符。`
31
  );
32
  }
 
 
 
33
  return normalized;
34
  }
35
 
@@ -103,6 +109,12 @@ export function describeInvalidImagesResponse(result: unknown): string {
103
  return 'API 返回的是 HTML 页面,不是 OpenAI Images JSON 响应。请确认 API URL 填的是兼容接口根地址,通常需要以 /v1 结尾,例如 https://api.openai.com/v1;不要填写管理后台或网页首页地址。';
104
  }
105
  }
 
 
 
 
 
 
106
  return 'API 返回的数据不是 OpenAI Images 格式。请确认 API URL 是 OpenAI 兼容接口,并且该接口支持 Images generate/edit。';
107
  }
108
 
 
9
  isCredentialFailure
10
  } from './channel-router';
11
  import { RequestValidationError } from './image-request-utils';
12
+ import { readAcceptedImageTaskDetails } from './accepted-image-task';
13
  import type { ImageGenerationBackend } from './image-upstream-strategy';
14
  import { getServerChannelState } from './server-channel-router';
15
  import { buildAccessCookie, outputDir, readBooleanEnv, serializeAccessCookie } from './server-runtime';
 
21
  export type ImageBackend = ImageGenerationBackend;
22
  export type RequestLogContext = { clientRequestId: string };
23
 
24
+ const HTTP_HEADER_VALUE_CONTROL_CHAR_PATTERN = /[\u0000-\u001f\u007f]/;
25
+
26
  export function readClientRequestId(formData: FormData): string | undefined {
27
  const value = formData.get('clientRequestId');
28
  if (typeof value !== 'string') return undefined;
 
33
  `clientRequestId 长度不能超过 ${PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH} 个字符。`
34
  );
35
  }
36
+ if (HTTP_HEADER_VALUE_CONTROL_CHAR_PATTERN.test(normalized)) {
37
+ throw new RequestValidationError('clientRequestId 不能包含控制字符。');
38
+ }
39
  return normalized;
40
  }
41
 
 
109
  return 'API 返回的是 HTML 页面,不是 OpenAI Images JSON 响应。请确认 API URL 填的是兼容接口根地址,通常需要以 /v1 结尾,例如 https://api.openai.com/v1;不要填写管理后台或网页首页地址。';
110
  }
111
  }
112
+ const acceptedTask = readAcceptedImageTaskDetails(result);
113
+ if (acceptedTask) {
114
+ const taskSuffix = acceptedTask.taskId ? ` task_id=${acceptedTask.taskId}` : '';
115
+ const pollSuffix = acceptedTask.pollUrl ? ' poll_url=present' : '';
116
+ return `上游返回了异步图片任务${taskSuffix}${pollSuffix},不是可直接消费的 OpenAI Images 完成结果。如果同一业务幂等键有界重试后仍拿不到最终图片,就不能把该渠道配置为 images-non-stream;只有同键重试可返回最终图片,或上游轮询接口真实可用且服务端已接入任务轮询后,才能配置该请求方式。`;
117
+ }
118
  return 'API 返回的数据不是 OpenAI Images 格式。请确认 API URL 是 OpenAI 兼容接口,并且该接口支持 Images generate/edit。';
119
  }
120
 
src/lib/image-service.test.ts ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ AcceptedImageTaskResponseError,
3
+ assertOpenAiImagesResponse,
4
+ readAcceptedImageTaskDetails,
5
+ readRetryAfterSecondsHeader,
6
+ resolveAcceptedImageTaskResponse
7
+ } from './image-service';
8
+ import assert from 'node:assert/strict';
9
+ import { describe, it } from 'node:test';
10
+ import type OpenAI from 'openai';
11
+
12
+ describe('readAcceptedImageTaskDetails', () => {
13
+ it('extracts task metadata from async upstream image payloads', () => {
14
+ assert.deepEqual(
15
+ readAcceptedImageTaskDetails({
16
+ object: 'image.task',
17
+ status: 'pending',
18
+ task_id: ' sync-gen-task ',
19
+ poll_url: ' /api/image-tasks?ids=sync-gen-task '
20
+ }),
21
+ {
22
+ taskId: 'sync-gen-task',
23
+ pollUrl: '/api/image-tasks?ids=sync-gen-task'
24
+ }
25
+ );
26
+ });
27
+
28
+ it('ignores regular image responses', () => {
29
+ assert.equal(readAcceptedImageTaskDetails({ data: [{ b64_json: 'abc' }] }), undefined);
30
+ });
31
+
32
+ it('requires accepted task object and pending status', () => {
33
+ assert.equal(readAcceptedImageTaskDetails(null), undefined);
34
+ assert.equal(readAcceptedImageTaskDetails([]), undefined);
35
+ assert.equal(readAcceptedImageTaskDetails({ object: 'image.task', status: 'completed' }), undefined);
36
+ assert.equal(readAcceptedImageTaskDetails({ object: 'image.job', status: 'pending' }), undefined);
37
+ assert.deepEqual(readAcceptedImageTaskDetails({ object: 'image.task', status: 'pending' }), {});
38
+ assert.deepEqual(readAcceptedImageTaskDetails({ object: ' image.task ', status: ' pending ' }), {});
39
+ });
40
+ });
41
+
42
+ describe('assertOpenAiImagesResponse', () => {
43
+ it('rejects async task payloads that are not final OpenAI Images results', () => {
44
+ assert.throws(() =>
45
+ assertOpenAiImagesResponse({
46
+ object: 'image.task',
47
+ status: 'pending',
48
+ task_id: 'sync-gen-task'
49
+ })
50
+ );
51
+ });
52
+ });
53
+
54
+ describe('resolveAcceptedImageTaskResponse', () => {
55
+ it('retries accepted task payloads until the final image result arrives', async () => {
56
+ const calls: number[] = [];
57
+ const sleeps: number[] = [];
58
+ const result = await resolveAcceptedImageTaskResponse(
59
+ async () => {
60
+ calls.push(Date.now());
61
+ if (calls.length === 1) {
62
+ return {
63
+ data: {
64
+ object: 'image.task',
65
+ status: 'pending',
66
+ task_id: 'sync-gen-task',
67
+ poll_url: '/api/image-tasks?ids=sync-gen-task'
68
+ } as unknown as OpenAI.Images.ImagesResponse,
69
+ response: new Response('', { headers: { 'retry-after': '1' } })
70
+ };
71
+ }
72
+ return {
73
+ data: {
74
+ data: [{ b64_json: 'final-base64' }],
75
+ created: 123
76
+ } as OpenAI.Images.ImagesResponse,
77
+ response: new Response('', { headers: { 'retry-after': '1' } })
78
+ };
79
+ },
80
+ {
81
+ sleep: async (ms) => {
82
+ sleeps.push(ms);
83
+ }
84
+ }
85
+ );
86
+
87
+ assert.equal(calls.length, 2);
88
+ assert.deepEqual(sleeps, [1000]);
89
+ assert.equal(result.data[0]?.b64_json, 'final-base64');
90
+ });
91
+
92
+ it('throws a structured error after exhausting accepted-task retries', async () => {
93
+ await assert.rejects(
94
+ () =>
95
+ resolveAcceptedImageTaskResponse(
96
+ async () => ({
97
+ data: {
98
+ object: 'image.task',
99
+ status: 'pending',
100
+ task_id: 'sync-gen-task',
101
+ poll_url: '/api/image-tasks?ids=sync-gen-task'
102
+ } as unknown as OpenAI.Images.ImagesResponse,
103
+ response: new Response('', { headers: { 'retry-after': '1' } })
104
+ }),
105
+ { maxAttempts: 1 }
106
+ ),
107
+ (error) => {
108
+ assert.ok(error instanceof AcceptedImageTaskResponseError);
109
+ assert.equal(error.taskId, 'sync-gen-task');
110
+ assert.equal(error.pollUrl, '/api/image-tasks?ids=sync-gen-task');
111
+ assert.equal(error.retryAfterSeconds, 1);
112
+ return true;
113
+ }
114
+ );
115
+ });
116
+
117
+ it('uses the default retry delay for fractional Retry-After values', async () => {
118
+ const sleeps: number[] = [];
119
+ const result = await resolveAcceptedImageTaskResponse(
120
+ async () => {
121
+ if (sleeps.length === 0) {
122
+ return {
123
+ data: {
124
+ object: 'image.task',
125
+ status: 'pending',
126
+ task_id: 'fractional-retry-after-task'
127
+ } as unknown as OpenAI.Images.ImagesResponse,
128
+ response: new Response('', { headers: { 'retry-after': '0.5' } })
129
+ };
130
+ }
131
+ return {
132
+ data: {
133
+ data: [{ b64_json: 'final-base64' }],
134
+ created: 123
135
+ } as OpenAI.Images.ImagesResponse,
136
+ response: new Response('')
137
+ };
138
+ },
139
+ {
140
+ retryDelayMs: 2500,
141
+ sleep: async (ms) => {
142
+ sleeps.push(ms);
143
+ }
144
+ }
145
+ );
146
+
147
+ assert.deepEqual(sleeps, [2500]);
148
+ assert.equal(result.data[0]?.b64_json, 'final-base64');
149
+ });
150
+
151
+ it('caps fallback retry delays for accepted task responses', async () => {
152
+ const sleeps: number[] = [];
153
+ const result = await resolveAcceptedImageTaskResponse(
154
+ async () => {
155
+ if (sleeps.length === 0) {
156
+ return {
157
+ data: {
158
+ object: 'image.task',
159
+ status: 'pending',
160
+ task_id: 'large-fallback-delay-task'
161
+ } as unknown as OpenAI.Images.ImagesResponse,
162
+ response: new Response('')
163
+ };
164
+ }
165
+ return {
166
+ data: {
167
+ data: [{ b64_json: 'final-base64' }],
168
+ created: 123
169
+ } as OpenAI.Images.ImagesResponse,
170
+ response: new Response('')
171
+ };
172
+ },
173
+ {
174
+ retryDelayMs: 60_000,
175
+ sleep: async (ms) => {
176
+ sleeps.push(ms);
177
+ }
178
+ }
179
+ );
180
+
181
+ assert.deepEqual(sleeps, [15_000]);
182
+ assert.equal(result.data[0]?.b64_json, 'final-base64');
183
+ });
184
+
185
+ it('respects whole-second Retry-After values above the fallback cap', async () => {
186
+ const sleeps: number[] = [];
187
+ const result = await resolveAcceptedImageTaskResponse(
188
+ async () => {
189
+ if (sleeps.length === 0) {
190
+ return {
191
+ data: {
192
+ object: 'image.task',
193
+ status: 'pending',
194
+ task_id: 'long-retry-after-task'
195
+ } as unknown as OpenAI.Images.ImagesResponse,
196
+ response: new Response('', { headers: { 'retry-after': '30' } })
197
+ };
198
+ }
199
+ return {
200
+ data: {
201
+ data: [{ b64_json: 'final-base64' }],
202
+ created: 123
203
+ } as OpenAI.Images.ImagesResponse,
204
+ response: new Response('')
205
+ };
206
+ },
207
+ {
208
+ sleep: async (ms) => {
209
+ sleeps.push(ms);
210
+ }
211
+ }
212
+ );
213
+
214
+ assert.deepEqual(sleeps, [30_000]);
215
+ assert.equal(result.data[0]?.b64_json, 'final-base64');
216
+ });
217
+
218
+ it('caps excessive Retry-After values for accepted task responses', async () => {
219
+ const sleeps: number[] = [];
220
+ const result = await resolveAcceptedImageTaskResponse(
221
+ async () => {
222
+ if (sleeps.length === 0) {
223
+ return {
224
+ data: {
225
+ object: 'image.task',
226
+ status: 'pending',
227
+ task_id: 'excessive-retry-after-task'
228
+ } as unknown as OpenAI.Images.ImagesResponse,
229
+ response: new Response('', { headers: { 'retry-after': '999' } })
230
+ };
231
+ }
232
+ return {
233
+ data: {
234
+ data: [{ b64_json: 'final-base64' }],
235
+ created: 123
236
+ } as OpenAI.Images.ImagesResponse,
237
+ response: new Response('')
238
+ };
239
+ },
240
+ {
241
+ sleep: async (ms) => {
242
+ sleeps.push(ms);
243
+ }
244
+ }
245
+ );
246
+
247
+ assert.deepEqual(sleeps, [300_000]);
248
+ assert.equal(result.data[0]?.b64_json, 'final-base64');
249
+ });
250
+
251
+ it('stops accepted-task backoff when the caller aborts', async () => {
252
+ const controller = new AbortController();
253
+ let sleepSawSignal = false;
254
+
255
+ await assert.rejects(
256
+ () =>
257
+ resolveAcceptedImageTaskResponse(
258
+ async () => ({
259
+ data: {
260
+ object: 'image.task',
261
+ status: 'pending',
262
+ task_id: 'abortable-task'
263
+ } as unknown as OpenAI.Images.ImagesResponse,
264
+ response: new Response('')
265
+ }),
266
+ {
267
+ abortSignal: controller.signal,
268
+ sleep: async (_ms, abortSignal) => {
269
+ sleepSawSignal = abortSignal === controller.signal;
270
+ controller.abort(new Error('caller aborted'));
271
+ if (abortSignal?.aborted) {
272
+ throw abortSignal.reason;
273
+ }
274
+ }
275
+ }
276
+ ),
277
+ /caller aborted/
278
+ );
279
+
280
+ assert.equal(sleepSawSignal, true);
281
+ });
282
+
283
+ it('preserves string abort reasons before retrying accepted tasks', async () => {
284
+ const controller = new AbortController();
285
+ let operationCalled = false;
286
+ controller.abort('caller stopped');
287
+
288
+ await assert.rejects(
289
+ () =>
290
+ resolveAcceptedImageTaskResponse(
291
+ async () => {
292
+ operationCalled = true;
293
+ return {
294
+ data: {
295
+ object: 'image.task',
296
+ status: 'pending',
297
+ task_id: 'abort-string-reason-task'
298
+ } as unknown as OpenAI.Images.ImagesResponse,
299
+ response: new Response('')
300
+ };
301
+ },
302
+ { abortSignal: controller.signal }
303
+ ),
304
+ /caller stopped/
305
+ );
306
+ assert.equal(operationCalled, false);
307
+ });
308
+ });
309
+
310
+ describe('readRetryAfterSecondsHeader', () => {
311
+ it('accepts positive whole seconds only', () => {
312
+ assert.equal(readRetryAfterSecondsHeader('1'), 1);
313
+ assert.equal(readRetryAfterSecondsHeader(' 3 '), 3);
314
+ assert.equal(readRetryAfterSecondsHeader('15'), 15);
315
+ assert.equal(readRetryAfterSecondsHeader('0'), undefined);
316
+ assert.equal(readRetryAfterSecondsHeader('0.5'), undefined);
317
+ assert.equal(readRetryAfterSecondsHeader('2.4'), undefined);
318
+ assert.equal(readRetryAfterSecondsHeader('abc'), undefined);
319
+ assert.equal(readRetryAfterSecondsHeader('999999999999999999999'), undefined);
320
+ assert.equal(readRetryAfterSecondsHeader('Wed, 21 Oct 2015 07:28:00 GMT'), undefined);
321
+ assert.equal(readRetryAfterSecondsHeader(3), undefined);
322
+ });
323
+ });
src/lib/image-service.ts CHANGED
@@ -1,4 +1,5 @@
1
  import { detectImageFormat, readImageDimensions, writeFileAtomic } from './agent-file-utils';
 
2
  import { createImageResult, type StorageMode, type ValidOutputFormat } from './image-request-utils';
3
  import type { UpstreamRequestHeaders } from './image-upstream-profile';
4
  import { downloadSameOriginImageAsBase64 } from './image-url-result';
@@ -7,6 +8,14 @@ import fs from 'fs/promises';
7
  import type OpenAI from 'openai';
8
  import path from 'path';
9
 
 
 
 
 
 
 
 
 
10
  export type PersistedOpenAiImage = {
11
  filename: string;
12
  b64Json: string;
@@ -45,6 +54,21 @@ export class MissingOpenAiImageDataError extends Error {
45
  }
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  export function assertOpenAiImagesResponse(result: unknown): asserts result is ValidImagesResponse {
49
  const candidate = result as Partial<OpenAI.Images.ImagesResponse> | null | undefined;
50
  if (!candidate || typeof candidate !== 'object' || !Array.isArray(candidate.data) || candidate.data.length === 0) {
@@ -52,6 +76,89 @@ export function assertOpenAiImagesResponse(result: unknown): asserts result is V
52
  }
53
  }
54
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  export async function persistOpenAiImages(options: {
56
  result: OpenAI.Images.ImagesResponse;
57
  outputFormat: ValidOutputFormat;
 
1
  import { detectImageFormat, readImageDimensions, writeFileAtomic } from './agent-file-utils';
2
+ import { readAcceptedImageTaskDetails } from './accepted-image-task';
3
  import { createImageResult, type StorageMode, type ValidOutputFormat } from './image-request-utils';
4
  import type { UpstreamRequestHeaders } from './image-upstream-profile';
5
  import { downloadSameOriginImageAsBase64 } from './image-url-result';
 
8
  import type OpenAI from 'openai';
9
  import path from 'path';
10
 
11
+ export { readAcceptedImageTaskDetails } from './accepted-image-task';
12
+ export type { AcceptedImageTaskDetails } from './accepted-image-task';
13
+
14
+ const DEFAULT_ACCEPTED_IMAGE_TASK_MAX_ATTEMPTS = 3;
15
+ const DEFAULT_ACCEPTED_IMAGE_TASK_RETRY_DELAY_MS = 5_000;
16
+ const MAX_ACCEPTED_IMAGE_TASK_RETRY_DELAY_MS = 15_000;
17
+ const MAX_ACCEPTED_IMAGE_TASK_RETRY_AFTER_SECONDS = 300;
18
+
19
  export type PersistedOpenAiImage = {
20
  filename: string;
21
  b64Json: string;
 
54
  }
55
  }
56
 
57
+ export class AcceptedImageTaskResponseError extends Error {
58
+ readonly taskId?: string;
59
+ readonly pollUrl?: string;
60
+ readonly retryAfterSeconds?: number;
61
+ readonly status = 502;
62
+
63
+ constructor(input: { taskId?: string; pollUrl?: string; retryAfterSeconds?: number }) {
64
+ super('上游返回了异步图片任务,但没有拿到可直接消费的最终图片结果。');
65
+ this.name = 'AcceptedImageTaskResponseError';
66
+ this.taskId = input.taskId;
67
+ this.pollUrl = input.pollUrl;
68
+ this.retryAfterSeconds = input.retryAfterSeconds;
69
+ }
70
+ }
71
+
72
  export function assertOpenAiImagesResponse(result: unknown): asserts result is ValidImagesResponse {
73
  const candidate = result as Partial<OpenAI.Images.ImagesResponse> | null | undefined;
74
  if (!candidate || typeof candidate !== 'object' || !Array.isArray(candidate.data) || candidate.data.length === 0) {
 
76
  }
77
  }
78
 
79
+ export function readRetryAfterSecondsHeader(value: unknown): number | undefined {
80
+ if (typeof value !== 'string') return undefined;
81
+ const normalized = value.trim();
82
+ if (!/^[1-9]\d*$/.test(normalized)) return undefined;
83
+ const parsed = Number(normalized);
84
+ if (!Number.isSafeInteger(parsed) || parsed <= 0) return undefined;
85
+ return parsed;
86
+ }
87
+
88
+ export async function resolveAcceptedImageTaskResponse<T extends OpenAI.Images.ImagesResponse>(
89
+ operation: () => Promise<{ data: T; response?: Response }>,
90
+ options: {
91
+ abortSignal?: AbortSignal;
92
+ maxAttempts?: number;
93
+ retryDelayMs?: number;
94
+ sleep?: (ms: number, abortSignal?: AbortSignal) => Promise<void>;
95
+ onAcceptedTask?: (details: AcceptedImageTaskResponseError, attempt: number, delayMs: number) => void;
96
+ } = {}
97
+ ): Promise<T> {
98
+ const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_ACCEPTED_IMAGE_TASK_MAX_ATTEMPTS);
99
+ const sleep = options.sleep ?? delayAcceptedImageTaskResponse;
100
+
101
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
102
+ throwIfAcceptedImageTaskRetryAborted(options.abortSignal);
103
+ const { data, response } = await operation();
104
+ const acceptedTask = readAcceptedImageTaskDetails(data);
105
+ if (!acceptedTask) return data;
106
+
107
+ const retryDelayMs = readAcceptedImageTaskRetryDelayMs(
108
+ response?.headers.get('retry-after'),
109
+ options.retryDelayMs ?? DEFAULT_ACCEPTED_IMAGE_TASK_RETRY_DELAY_MS
110
+ );
111
+ const error = new AcceptedImageTaskResponseError({
112
+ ...acceptedTask,
113
+ retryAfterSeconds: Math.ceil(retryDelayMs / 1000)
114
+ });
115
+ if (attempt >= maxAttempts) throw error;
116
+ options.onAcceptedTask?.(error, attempt, retryDelayMs);
117
+ await sleep(retryDelayMs, options.abortSignal);
118
+ }
119
+
120
+ throw new Error('UNREACHABLE: resolveAcceptedImageTaskResponse exhausted without return or throw.');
121
+ }
122
+
123
+ function delayAcceptedImageTaskResponse(ms: number, abortSignal?: AbortSignal): Promise<void> {
124
+ return new Promise((resolve, reject) => {
125
+ const timeout = setTimeout(() => {
126
+ abortSignal?.removeEventListener('abort', onAbort);
127
+ resolve();
128
+ }, ms);
129
+
130
+ const onAbort = () => {
131
+ clearTimeout(timeout);
132
+ reject(readAbortReason(abortSignal));
133
+ };
134
+
135
+ if (abortSignal?.aborted) {
136
+ onAbort();
137
+ return;
138
+ }
139
+ abortSignal?.addEventListener('abort', onAbort, { once: true });
140
+ });
141
+ }
142
+
143
+ function throwIfAcceptedImageTaskRetryAborted(abortSignal?: AbortSignal): void {
144
+ if (!abortSignal?.aborted) return;
145
+ throw readAbortReason(abortSignal);
146
+ }
147
+
148
+ function readAbortReason(abortSignal?: AbortSignal): Error {
149
+ if (abortSignal?.reason instanceof Error) return abortSignal.reason;
150
+ if (abortSignal?.reason !== undefined) {
151
+ return new DOMException(`The operation was aborted: ${String(abortSignal.reason)}`, 'AbortError');
152
+ }
153
+ return new DOMException('The operation was aborted.', 'AbortError');
154
+ }
155
+
156
+ function readAcceptedImageTaskRetryDelayMs(value: unknown, fallbackMs: number): number {
157
+ const retryAfterSeconds = readRetryAfterSecondsHeader(value);
158
+ if (retryAfterSeconds === undefined) return Math.min(fallbackMs, MAX_ACCEPTED_IMAGE_TASK_RETRY_DELAY_MS);
159
+ return Math.min(retryAfterSeconds, MAX_ACCEPTED_IMAGE_TASK_RETRY_AFTER_SECONDS) * 1000;
160
+ }
161
+
162
  export async function persistOpenAiImages(options: {
163
  result: OpenAI.Images.ImagesResponse;
164
  outputFormat: ValidOutputFormat;
src/lib/image-stream-collector.test.ts CHANGED
@@ -1,4 +1,8 @@
1
- import { collectOpenAiImagesFromStream, MissingFinalImageStreamResultError } from './image-stream-collector';
 
 
 
 
2
  import { upstreamEvents } from './sse-test-utils';
3
  import assert from 'node:assert/strict';
4
  import http from 'node:http';
@@ -206,6 +210,58 @@ describe('collectOpenAiImagesFromStream', () => {
206
  );
207
  });
208
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  it('fails explicitly when upstream stream stays idle past the configured interval', async () => {
210
  let returnCalled = false;
211
  const stream: AsyncIterable<unknown> = {
 
1
+ import {
2
+ AcceptedImageTaskStreamResultError,
3
+ collectOpenAiImagesFromStream,
4
+ MissingFinalImageStreamResultError
5
+ } from './image-stream-collector';
6
  import { upstreamEvents } from './sse-test-utils';
7
  import assert from 'node:assert/strict';
8
  import http from 'node:http';
 
210
  );
211
  });
212
 
213
+ it('reports accepted image tasks explicitly when the upstream only returns task metadata', async () => {
214
+ await assert.rejects(
215
+ () =>
216
+ collectOpenAiImagesFromStream(
217
+ upstreamEvents([
218
+ {
219
+ id: 'sync-gen-task',
220
+ object: 'image.task',
221
+ status: 'pending',
222
+ task_id: 'sync-gen-task',
223
+ poll_url: '/api/image-tasks?ids=sync-gen-task',
224
+ message: 'Image task accepted. Poll poll_url with the same Authorization header.'
225
+ }
226
+ ])
227
+ ),
228
+ (error) => {
229
+ assert.ok(error instanceof AcceptedImageTaskStreamResultError);
230
+ assert.equal(error.taskId, 'sync-gen-task');
231
+ assert.equal(error.pollUrl, '/api/image-tasks?ids=sync-gen-task');
232
+ return true;
233
+ }
234
+ );
235
+ });
236
+
237
+ it('does not return partial image data after an accepted task event', async () => {
238
+ await assert.rejects(
239
+ () =>
240
+ collectOpenAiImagesFromStream(
241
+ upstreamEvents([
242
+ {
243
+ type: 'image_generation.partial_image',
244
+ partial_image_index: 0,
245
+ b64_json: 'partial-before-task'
246
+ },
247
+ {
248
+ id: 'partial-before-task-id',
249
+ object: 'image.task',
250
+ status: 'pending',
251
+ task_id: 'partial-before-task-id',
252
+ poll_url: '/api/image-tasks?ids=partial-before-task-id'
253
+ }
254
+ ])
255
+ ),
256
+ (error) => {
257
+ assert.ok(error instanceof AcceptedImageTaskStreamResultError);
258
+ assert.equal(error.taskId, 'partial-before-task-id');
259
+ assert.equal(error.pollUrl, '/api/image-tasks?ids=partial-before-task-id');
260
+ return true;
261
+ }
262
+ );
263
+ });
264
+
265
  it('fails explicitly when upstream stream stays idle past the configured interval', async () => {
266
  let returnCalled = false;
267
  const stream: AsyncIterable<unknown> = {
src/lib/image-stream-collector.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { normalizeUpstreamImageStreamEventWithDiagnostics } from './image-stream-events';
2
  import type { UpstreamRequestHeaders } from './image-upstream-profile';
3
  import { downloadSameOriginImageAsBase64 } from './image-url-result';
@@ -20,6 +21,19 @@ export class MissingFinalImageStreamResultError extends Error {
20
  }
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  export async function collectOpenAiImagesFromStream(
24
  stream: AsyncIterable<unknown>,
25
  options: {
@@ -36,12 +50,17 @@ export async function collectOpenAiImagesFromStream(
36
  let usage: ImageUsage | undefined;
37
  let upstreamEventType: string | undefined;
38
  let partialImageCount = 0;
 
39
 
40
  for await (const event of withStreamDataIntervalTimeout(
41
  stream,
42
  options.streamDataIntervalTimeoutMs ?? readImageStreamDataIntervalTimeoutMs()
43
  )) {
44
  const diagnostics = normalizeUpstreamImageStreamEventWithDiagnostics(event);
 
 
 
 
45
  if (diagnostics.providerDialect === 'sdk_parsed_fallback') {
46
  options.onStreamingDegraded?.('json_final_fallback');
47
  }
@@ -80,6 +99,10 @@ export async function collectOpenAiImagesFromStream(
80
  }
81
  }
82
 
 
 
 
 
83
  if (data.length === 0) {
84
  throw new MissingFinalImageStreamResultError({ upstreamEventType, partialImageCount });
85
  }
 
1
+ import { readAcceptedImageTaskDetails } from './accepted-image-task';
2
  import { normalizeUpstreamImageStreamEventWithDiagnostics } from './image-stream-events';
3
  import type { UpstreamRequestHeaders } from './image-upstream-profile';
4
  import { downloadSameOriginImageAsBase64 } from './image-url-result';
 
21
  }
22
  }
23
 
24
+ export class AcceptedImageTaskStreamResultError extends Error {
25
+ readonly status = 502;
26
+ readonly taskId?: string;
27
+ readonly pollUrl?: string;
28
+
29
+ constructor(input: { taskId?: string; pollUrl?: string }) {
30
+ super('上游返回了异步图片任务,但当前服务不支持该任务态的自动轮询。');
31
+ this.name = 'AcceptedImageTaskStreamResultError';
32
+ this.taskId = input.taskId;
33
+ this.pollUrl = input.pollUrl;
34
+ }
35
+ }
36
+
37
  export async function collectOpenAiImagesFromStream(
38
  stream: AsyncIterable<unknown>,
39
  options: {
 
50
  let usage: ImageUsage | undefined;
51
  let upstreamEventType: string | undefined;
52
  let partialImageCount = 0;
53
+ let acceptedTaskInfo: { taskId?: string; pollUrl?: string } | undefined;
54
 
55
  for await (const event of withStreamDataIntervalTimeout(
56
  stream,
57
  options.streamDataIntervalTimeoutMs ?? readImageStreamDataIntervalTimeoutMs()
58
  )) {
59
  const diagnostics = normalizeUpstreamImageStreamEventWithDiagnostics(event);
60
+ if (diagnostics.providerDialect === 'image_task_accepted') {
61
+ acceptedTaskInfo = readAcceptedImageTaskDetails(event) ?? {};
62
+ continue;
63
+ }
64
  if (diagnostics.providerDialect === 'sdk_parsed_fallback') {
65
  options.onStreamingDegraded?.('json_final_fallback');
66
  }
 
99
  }
100
  }
101
 
102
+ if (acceptedTaskInfo) {
103
+ throw new AcceptedImageTaskStreamResultError(acceptedTaskInfo);
104
+ }
105
+
106
  if (data.length === 0) {
107
  throw new MissingFinalImageStreamResultError({ upstreamEventType, partialImageCount });
108
  }
src/lib/image-stream-events.test.ts CHANGED
@@ -620,4 +620,19 @@ describe('normalizeUpstreamImageStreamEvent', () => {
620
  assert.equal(unknown.providerDialect, 'unknown_ignored_event');
621
  assert.deepEqual(unknown.events, []);
622
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
623
  });
 
620
  assert.equal(unknown.providerDialect, 'unknown_ignored_event');
621
  assert.deepEqual(unknown.events, []);
622
  });
623
+
624
+ it('recognizes accepted image task payloads as a dedicated provider dialect', () => {
625
+ const result = normalizeUpstreamImageStreamEventWithDiagnostics({
626
+ id: 'sync-gen-task',
627
+ object: 'image.task',
628
+ status: 'pending',
629
+ task_id: 'sync-gen-task',
630
+ poll_url: '/api/image-tasks?ids=sync-gen-task',
631
+ message: 'Image task accepted. Poll poll_url with the same Authorization header.'
632
+ });
633
+
634
+ assert.equal(result.providerDialect, 'image_task_accepted');
635
+ assert.deepEqual(result.events, []);
636
+ assert.equal(result.upstreamEventType, undefined);
637
+ });
638
  });
src/lib/image-stream-events.ts CHANGED
@@ -1,3 +1,4 @@
 
1
  import { extractImageBase64FromDataUrl, isRemoteHttpUrl, readResponsesImageResultBase64 } from './image-payload';
2
  import { asRecord, type JsonRecord } from './json-record';
3
  import { createHash } from 'node:crypto';
@@ -9,6 +10,7 @@ export type ImageStreamProviderDialect =
9
  | 'official_image_event'
10
  | 'responses_image_event'
11
  | 'otokapi_image_event'
 
12
  | 'sdk_parsed_fallback'
13
  | 'unknown_ignored_event';
14
 
@@ -417,8 +419,12 @@ function normalizeCompletedEvent(record: JsonRecord, eventType: string | undefin
417
 
418
  function classifyProviderDialect(
419
  eventType: string | undefined,
420
- events: NormalizedImageStreamEvent[]
 
421
  ): ImageStreamProviderDialect {
 
 
 
422
  if (eventType && OFFICIAL_EVENT_TYPES.has(eventType)) {
423
  return 'official_image_event';
424
  }
@@ -459,7 +465,7 @@ export function normalizeUpstreamImageStreamEventWithDiagnostics(event: unknown)
459
  events = [];
460
  }
461
 
462
- const providerDialect = classifyProviderDialect(eventType, events);
463
  return {
464
  events,
465
  providerDialect,
 
1
+ import { readAcceptedImageTaskDetails } from './accepted-image-task';
2
  import { extractImageBase64FromDataUrl, isRemoteHttpUrl, readResponsesImageResultBase64 } from './image-payload';
3
  import { asRecord, type JsonRecord } from './json-record';
4
  import { createHash } from 'node:crypto';
 
10
  | 'official_image_event'
11
  | 'responses_image_event'
12
  | 'otokapi_image_event'
13
+ | 'image_task_accepted'
14
  | 'sdk_parsed_fallback'
15
  | 'unknown_ignored_event';
16
 
 
419
 
420
  function classifyProviderDialect(
421
  eventType: string | undefined,
422
+ events: NormalizedImageStreamEvent[],
423
+ record: JsonRecord
424
  ): ImageStreamProviderDialect {
425
+ if (readAcceptedImageTaskDetails(record)) {
426
+ return 'image_task_accepted';
427
+ }
428
  if (eventType && OFFICIAL_EVENT_TYPES.has(eventType)) {
429
  return 'official_image_event';
430
  }
 
465
  events = [];
466
  }
467
 
468
+ const providerDialect = classifyProviderDialect(eventType, events, record);
469
  return {
470
  events,
471
  providerDialect,
src/lib/images-api-stream.test.ts CHANGED
@@ -7,7 +7,6 @@ const originalFetch = globalThis.fetch;
7
  afterEach(() => {
8
  globalThis.fetch = originalFetch;
9
  });
10
-
11
  describe('createImagesApiGenerateStream', () => {
12
  it('keeps fixed protocol and authorization headers ahead of upstream extras', async () => {
13
  let observedAuthorization: string | null = null;
@@ -15,6 +14,7 @@ describe('createImagesApiGenerateStream', () => {
15
  let observedContentType: string | null = null;
16
  let observedUserAgent: string | null = null;
17
  let observedAppId: string | null = null;
 
18
  globalThis.fetch = async (_url, init) => {
19
  const headers = new Headers(init?.headers);
20
  observedAuthorization = headers.get('authorization');
@@ -22,6 +22,7 @@ describe('createImagesApiGenerateStream', () => {
22
  observedContentType = headers.get('content-type');
23
  observedUserAgent = headers.get('user-agent');
24
  observedAppId = headers.get('x-app-id');
 
25
  return new Response(JSON.stringify({ data: [] }), {
26
  status: 200,
27
  headers: { 'content-type': 'application/json' }
@@ -37,10 +38,12 @@ describe('createImagesApiGenerateStream', () => {
37
  stream: true,
38
  partial_images: 2
39
  },
 
40
  upstreamHeaders: {
41
  Authorization: 'Bearer wrong-key',
42
  Accept: 'application/json',
43
  'Content-Type': 'text/plain',
 
44
  'X-App-ID': 'app-id'
45
  }
46
  });
@@ -50,6 +53,7 @@ describe('createImagesApiGenerateStream', () => {
50
  assert.equal(observedContentType, 'application/json');
51
  assert.equal(observedUserAgent, 'gpt-image-playground/2.1.0');
52
  assert.equal(observedAppId, 'app-id');
 
53
  });
54
 
55
  it('aborts hanging stream setup using the configured upstream timeout', async () => {
 
7
  afterEach(() => {
8
  globalThis.fetch = originalFetch;
9
  });
 
10
  describe('createImagesApiGenerateStream', () => {
11
  it('keeps fixed protocol and authorization headers ahead of upstream extras', async () => {
12
  let observedAuthorization: string | null = null;
 
14
  let observedContentType: string | null = null;
15
  let observedUserAgent: string | null = null;
16
  let observedAppId: string | null = null;
17
+ let observedIdempotencyKey: string | null = null;
18
  globalThis.fetch = async (_url, init) => {
19
  const headers = new Headers(init?.headers);
20
  observedAuthorization = headers.get('authorization');
 
22
  observedContentType = headers.get('content-type');
23
  observedUserAgent = headers.get('user-agent');
24
  observedAppId = headers.get('x-app-id');
25
+ observedIdempotencyKey = headers.get('idempotency-key');
26
  return new Response(JSON.stringify({ data: [] }), {
27
  status: 200,
28
  headers: { 'content-type': 'application/json' }
 
38
  stream: true,
39
  partial_images: 2
40
  },
41
+ idempotencyKey: 'stream-idempotency-key',
42
  upstreamHeaders: {
43
  Authorization: 'Bearer wrong-key',
44
  Accept: 'application/json',
45
  'Content-Type': 'text/plain',
46
+ 'Idempotency-Key': 'wrong-key',
47
  'X-App-ID': 'app-id'
48
  }
49
  });
 
53
  assert.equal(observedContentType, 'application/json');
54
  assert.equal(observedUserAgent, 'gpt-image-playground/2.1.0');
55
  assert.equal(observedAppId, 'app-id');
56
+ assert.equal(observedIdempotencyKey, 'stream-idempotency-key');
57
  });
58
 
59
  it('aborts hanging stream setup using the configured upstream timeout', async () => {
src/lib/images-api-stream.ts CHANGED
@@ -16,6 +16,7 @@ type ImagesApiStreamInput = {
16
  apiBaseUrl?: string;
17
  apiKey: string;
18
  upstreamHeaders?: UpstreamRequestHeaders;
 
19
  abortSignal?: AbortSignal;
20
  timeoutMs?: number;
21
  params: OpenAI.Images.ImageGenerateParamsStreaming;
@@ -132,7 +133,7 @@ function readSseChunk(chunk: string): unknown | undefined {
132
  }
133
 
134
  export async function createImagesApiGenerateStream(input: ImagesApiStreamInput): Promise<AsyncIterable<unknown>> {
135
- const { abortSignal, apiBaseUrl, apiKey, params, upstreamHeaders } = input;
136
  const abortContext = createAbortContext({ abortSignal, timeoutMs: input.timeoutMs });
137
  let response: Response;
138
  try {
@@ -141,7 +142,8 @@ export async function createImagesApiGenerateStream(input: ImagesApiStreamInput)
141
  headers: mergeUpstreamHeadersWithFixed(upstreamHeaders, {
142
  Authorization: `Bearer ${apiKey}`,
143
  'Content-Type': 'application/json',
144
- Accept: 'text/event-stream, application/json'
 
145
  }),
146
  signal: abortContext.signal,
147
  body: JSON.stringify(params)
 
16
  apiBaseUrl?: string;
17
  apiKey: string;
18
  upstreamHeaders?: UpstreamRequestHeaders;
19
+ idempotencyKey?: string;
20
  abortSignal?: AbortSignal;
21
  timeoutMs?: number;
22
  params: OpenAI.Images.ImageGenerateParamsStreaming;
 
133
  }
134
 
135
  export async function createImagesApiGenerateStream(input: ImagesApiStreamInput): Promise<AsyncIterable<unknown>> {
136
+ const { abortSignal, apiBaseUrl, apiKey, idempotencyKey, params, upstreamHeaders } = input;
137
  const abortContext = createAbortContext({ abortSignal, timeoutMs: input.timeoutMs });
138
  let response: Response;
139
  try {
 
142
  headers: mergeUpstreamHeadersWithFixed(upstreamHeaders, {
143
  Authorization: `Bearer ${apiKey}`,
144
  'Content-Type': 'application/json',
145
+ Accept: 'text/event-stream, application/json',
146
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
147
  }),
148
  signal: abortContext.signal,
149
  body: JSON.stringify(params)
src/lib/openai-image-transport.test.ts CHANGED
@@ -43,6 +43,18 @@ describe('openai image transport settings', () => {
43
  assert.equal(buildOpenAIImageRequestOptions({ env }).maxRetries, 1);
44
  });
45
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  it('rejects invalid transport env values explicitly', () => {
47
  assert.throws(() => readImageUpstreamTimeoutMs({ IMAGE_UPSTREAM_TIMEOUT_MS: '15s' }), /非负整数/);
48
  assert.throws(() => readImageUpstreamTimeoutMs({ IMAGE_UPSTREAM_TIMEOUT_MS: '0' }), /正整数/);
 
43
  assert.equal(buildOpenAIImageRequestOptions({ env }).maxRetries, 1);
44
  });
45
 
46
+ it('sends the idempotency key as an explicit upstream header', () => {
47
+ const options = buildOpenAIImageRequestOptions({
48
+ headers: { 'Idempotency-Key': 'configured-wrong-key', 'X-App-ID': 'app-id' },
49
+ idempotencyKey: 'business-operation-key'
50
+ });
51
+ const headers = new Headers(options.headers as HeadersInit);
52
+
53
+ assert.equal(options.idempotencyKey, 'business-operation-key');
54
+ assert.equal(headers.get('idempotency-key'), 'business-operation-key');
55
+ assert.equal(headers.get('x-app-id'), 'app-id');
56
+ });
57
+
58
  it('rejects invalid transport env values explicitly', () => {
59
  assert.throws(() => readImageUpstreamTimeoutMs({ IMAGE_UPSTREAM_TIMEOUT_MS: '15s' }), /非负整数/);
60
  assert.throws(() => readImageUpstreamTimeoutMs({ IMAGE_UPSTREAM_TIMEOUT_MS: '0' }), /正整数/);
src/lib/openai-image-transport.ts CHANGED
@@ -42,12 +42,15 @@ export function buildOpenAIImageRequestOptions(
42
  input: {
43
  abortSignal?: AbortSignal;
44
  headers?: OpenAI.RequestOptions['headers'];
 
45
  env?: ImageTransportEnv;
46
  } = {}
47
  ): OpenAI.RequestOptions {
 
48
  return {
49
  ...(input.abortSignal ? { signal: input.abortSignal } : {}),
50
- ...(input.headers ? { headers: input.headers } : {}),
 
51
  timeout: readImageUpstreamTimeoutMs(input.env),
52
  maxRetries: readImageUpstreamMaxRetries(input.env)
53
  };
@@ -81,3 +84,20 @@ function readPositiveIntegerEnv(env: ImageTransportEnv, fieldName: string, fallb
81
  }
82
  return value;
83
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  input: {
43
  abortSignal?: AbortSignal;
44
  headers?: OpenAI.RequestOptions['headers'];
45
+ idempotencyKey?: string;
46
  env?: ImageTransportEnv;
47
  } = {}
48
  ): OpenAI.RequestOptions {
49
+ const headers = mergeRequestHeaders(input.headers, readIdempotencyHeader(input.idempotencyKey));
50
  return {
51
  ...(input.abortSignal ? { signal: input.abortSignal } : {}),
52
+ ...(headers ? { headers } : {}),
53
+ ...(input.idempotencyKey ? { idempotencyKey: input.idempotencyKey } : {}),
54
  timeout: readImageUpstreamTimeoutMs(input.env),
55
  maxRetries: readImageUpstreamMaxRetries(input.env)
56
  };
 
84
  }
85
  return value;
86
  }
87
+
88
+ function readIdempotencyHeader(idempotencyKey: string | undefined): Record<string, string> | undefined {
89
+ const value = idempotencyKey?.trim();
90
+ return value ? { 'Idempotency-Key': value } : undefined;
91
+ }
92
+
93
+ function mergeRequestHeaders(
94
+ headers: OpenAI.RequestOptions['headers'] | undefined,
95
+ fixedHeaders: Record<string, string> | undefined
96
+ ): OpenAI.RequestOptions['headers'] | undefined {
97
+ if (!fixedHeaders) return headers;
98
+ const normalizedHeaders = new Headers(headers as HeadersInit | undefined);
99
+ for (const [name, value] of Object.entries(fixedHeaders)) {
100
+ normalizedHeaders.set(name, value);
101
+ }
102
+ return normalizedHeaders;
103
+ }
src/lib/responses-image-backend.ts CHANGED
@@ -35,6 +35,7 @@ export type ResponsesImageGenerateInput = {
35
  outputCompression?: number;
36
  promptOptimization?: boolean;
37
  thinking?: string;
 
38
  abortSignal?: AbortSignal;
39
  };
40
 
@@ -196,7 +197,7 @@ export async function generateImageWithResponsesBackend(
196
  tool_choice: { type: 'image_generation' },
197
  tools: [buildResponsesImageTool(input)]
198
  },
199
- buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal })
200
  );
201
  const imageResults = extractCompletedImageResults(response.output);
202
 
@@ -226,7 +227,7 @@ export async function editImageWithResponsesBackend(
226
  tool_choice: { type: 'image_generation' },
227
  tools: [await buildResponsesImageEditTool(input)]
228
  },
229
- buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal })
230
  );
231
  const imageResults = extractCompletedImageResults(response.output);
232
 
@@ -254,7 +255,7 @@ export async function createResponsesImageStream(input: ResponsesImageStreamInpu
254
  tool_choice: { type: 'image_generation' },
255
  tools: [buildResponsesImageTool(input, input.partialImagesCount)]
256
  },
257
- buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal })
258
  );
259
  }
260
 
@@ -269,6 +270,6 @@ export async function createResponsesImageEditStream(
269
  tool_choice: { type: 'image_generation' },
270
  tools: [await buildResponsesImageEditTool(input, input.partialImagesCount)]
271
  },
272
- buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal })
273
  );
274
  }
 
35
  outputCompression?: number;
36
  promptOptimization?: boolean;
37
  thinking?: string;
38
+ idempotencyKey?: string;
39
  abortSignal?: AbortSignal;
40
  };
41
 
 
197
  tool_choice: { type: 'image_generation' },
198
  tools: [buildResponsesImageTool(input)]
199
  },
200
+ buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal, idempotencyKey: input.idempotencyKey })
201
  );
202
  const imageResults = extractCompletedImageResults(response.output);
203
 
 
227
  tool_choice: { type: 'image_generation' },
228
  tools: [await buildResponsesImageEditTool(input)]
229
  },
230
+ buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal, idempotencyKey: input.idempotencyKey })
231
  );
232
  const imageResults = extractCompletedImageResults(response.output);
233
 
 
255
  tool_choice: { type: 'image_generation' },
256
  tools: [buildResponsesImageTool(input, input.partialImagesCount)]
257
  },
258
+ buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal, idempotencyKey: input.idempotencyKey })
259
  );
260
  }
261
 
 
270
  tool_choice: { type: 'image_generation' },
271
  tools: [await buildResponsesImageEditTool(input, input.partialImagesCount)]
272
  },
273
+ buildOpenAIImageRequestOptions({ abortSignal: input.abortSignal, idempotencyKey: input.idempotencyKey })
274
  );
275
  }
src/lib/upstream-request-headers.test.ts CHANGED
@@ -61,6 +61,14 @@ describe('upstream request headers', () => {
61
  ),
62
  /不能配置 Content-Type/
63
  );
 
 
 
 
 
 
 
 
64
  });
65
 
66
  it('summarizes request headers without exposing secret values', () => {
 
61
  ),
62
  /不能配置 Content-Type/
63
  );
64
+ assert.throws(
65
+ () =>
66
+ normalizeConfiguredUpstreamHeaders(
67
+ { 'Idempotency-Key': 'same-key-for-every-request' },
68
+ 'OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON'
69
+ ),
70
+ /不能配置 Idempotency-Key/
71
+ );
72
  });
73
 
74
  it('summarizes request headers without exposing secret values', () => {
src/lib/upstream-request-headers.ts CHANGED
@@ -10,12 +10,22 @@ export type UpstreamRequestHeaderSummary = {
10
  };
11
 
12
  const DEFAULT_PRODUCT_USER_AGENT = `gpt-image-playground/${packageJson.version}`;
13
- const CONFIGURABLE_HEADER_BLOCKLIST = new Set(['authorization', 'accept', 'content-type', 'content-length', 'host']);
 
 
 
 
 
 
 
 
 
14
  const CANONICAL_HEADER_NAMES: Record<string, string> = {
15
  accept: 'Accept',
16
  authorization: 'Authorization',
17
  'content-length': 'Content-Length',
18
  'content-type': 'Content-Type',
 
19
  'user-agent': 'User-Agent',
20
  'x-app-id': 'X-App-ID',
21
  'x-app-secret': 'X-App-Secret'
@@ -68,7 +78,10 @@ export function mergeUpstreamHeadersWithFixed(
68
  const extraHeaders = normalizeHeaderMap(upstreamHeaders, { rejectBlocked: false });
69
  const fixedNames = new Set(Object.keys(fixedHeaders).map((name) => name.toLowerCase()));
70
  const filteredExtraHeaders = Object.fromEntries(
71
- Object.entries({ ...baseHeaders, ...extraHeaders }).filter(([name]) => !fixedNames.has(name.toLowerCase()))
 
 
 
72
  );
73
  return {
74
  ...filteredExtraHeaders,
 
10
  };
11
 
12
  const DEFAULT_PRODUCT_USER_AGENT = `gpt-image-playground/${packageJson.version}`;
13
+ const CONFIGURABLE_HEADER_BLOCKLIST = new Set([
14
+ 'authorization',
15
+ 'accept',
16
+ 'content-type',
17
+ 'content-length',
18
+ 'host',
19
+ 'idempotency-key'
20
+ ]);
21
+ // Defense-in-depth: fixed idempotency headers must never leak from extra upstream headers.
22
+ const ALWAYS_FILTERED_EXTRA_HEADER_NAMES = new Set(['idempotency-key']);
23
  const CANONICAL_HEADER_NAMES: Record<string, string> = {
24
  accept: 'Accept',
25
  authorization: 'Authorization',
26
  'content-length': 'Content-Length',
27
  'content-type': 'Content-Type',
28
+ 'idempotency-key': 'Idempotency-Key',
29
  'user-agent': 'User-Agent',
30
  'x-app-id': 'X-App-ID',
31
  'x-app-secret': 'X-App-Secret'
 
78
  const extraHeaders = normalizeHeaderMap(upstreamHeaders, { rejectBlocked: false });
79
  const fixedNames = new Set(Object.keys(fixedHeaders).map((name) => name.toLowerCase()));
80
  const filteredExtraHeaders = Object.fromEntries(
81
+ Object.entries({ ...baseHeaders, ...extraHeaders }).filter(([name]) => {
82
+ const lowerName = name.toLowerCase();
83
+ return !fixedNames.has(lowerName) && !ALWAYS_FILTERED_EXTRA_HEADER_NAMES.has(lowerName);
84
+ })
85
  );
86
  return {
87
  ...filteredExtraHeaders,