Spaces:
Running
Running
Deploy eefbbba to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@eefbbbad2b16424e315cc90a8e789882e4925175
- public/hf-space-deploy-marker.json +3 -3
- scripts/smoke-image-upstream-local-final-gate.test.mjs +2 -1
- skills/gpt-image-playground-agent/SKILL.md +1 -1
- src/app/api/agent/agent-routes.test.ts +48 -2
- src/app/api/deploy-marker/route.ts +1 -1
- src/app/api/images/route-test-helpers.ts +56 -0
- src/app/api/images/route.test.ts +158 -0
- src/app/api/images/route.ts +5 -0
- src/lib/accepted-image-task.ts +23 -0
- src/lib/agent-image-service.test.ts +21 -1
- src/lib/agent-image-service.ts +116 -29
- src/lib/api-error-response.test.ts +43 -0
- src/lib/api-error-response.ts +21 -0
- src/lib/image-route-mode-handlers.ts +36 -2
- src/lib/image-route-support.test.ts +40 -0
- src/lib/image-route-support.ts +12 -0
- src/lib/image-service.test.ts +323 -0
- src/lib/image-service.ts +107 -0
- src/lib/image-stream-collector.test.ts +57 -1
- src/lib/image-stream-collector.ts +23 -0
- src/lib/image-stream-events.test.ts +15 -0
- src/lib/image-stream-events.ts +8 -2
- src/lib/images-api-stream.test.ts +5 -1
- src/lib/images-api-stream.ts +4 -2
- src/lib/openai-image-transport.test.ts +12 -0
- src/lib/openai-image-transport.ts +21 -1
- src/lib/responses-image-backend.ts +5 -4
- src/lib/upstream-request-headers.test.ts +8 -0
- src/lib/upstream-request-headers.ts +15 -2
public/hf-space-deploy-marker.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
-
"local_sha": "
|
| 4 |
-
"created_at": "2026-
|
| 5 |
-
"deploy_id": "
|
| 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 |
-
|
|
|
|
| 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: (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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":"
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 477 |
-
|
| 478 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
);
|
| 480 |
}
|
| 481 |
const fallback = () =>
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 718 |
-
channelRequestMode:
|
| 719 |
};
|
| 720 |
-
channelLease = await acquireAgentChannelCapacity(
|
| 721 |
const result = shouldUseAgentUpstreamStream(streamOptions)
|
| 722 |
? await executeAgentEditStream({
|
| 723 |
-
credentialContext,
|
| 724 |
params: editParams,
|
| 725 |
streamOptions,
|
|
|
|
| 726 |
abortSignal: options.abortSignal
|
| 727 |
})
|
| 728 |
-
: await
|
| 729 |
-
|
| 730 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 746 |
-
apiKey:
|
| 747 |
-
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:
|
| 761 |
-
channelRequestModeFallbackApplied:
|
| 762 |
-
channelRequestModeDecision:
|
| 763 |
-
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,
|
| 786 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
:
|
|
|
|
|
|
|
| 1154 |
status: 502,
|
| 1155 |
-
retryable:
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
...(
|
|
|
|
| 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([
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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]) =>
|
|
|
|
|
|
|
|
|
|
| 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,
|