misonL commited on
Commit
a51c565
·
verified ·
1 Parent(s): 6c12d18

Deploy e9050df to Docker Space

Browse files

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

README.md CHANGED
@@ -91,23 +91,23 @@ start-windows.bat
91
 
92
  完整配置说明见 [.env.example](./.env.example)。README 只列常用项。
93
 
94
- | 场景 | 变量 | 说明 |
95
- | --- | --- | --- |
96
- | 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
97
- | 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
98
- | Agent 鉴权 | `AGENT_API_TOKEN` | 设置后 `/api/agent/*` 需要 Bearer token。 |
99
- | Agent 公开地址 | `AGENT_PUBLIC_BASE_URL` | OpenAPI `servers[0].url` 和 Agent artifact 分享外链使用的公网 base URL。 |
100
- | 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | `fs` 或 `indexeddb`。Docker 默认使用 `fs`。 |
101
- | Agent 状态 | `AGENT_STATE_BACKEND` | `memory`、`sqlite` 或 `postgres`。Docker 默认使用 `sqlite`。 |
102
- | 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
103
- | Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
104
- | 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
105
- | 渠道请求方式 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 可选。声明全局或单渠道可用方式;未配置时默认只允许 `images-non-stream`。每个渠道只保留真实 smoke 通过且能被本服务消费的模式,结果必须是内联 `b64_json`、Responses `result` 或同源 artifact URL。新上游若 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就不要把 `responses-non-stream`、`responses-sse` 放进该渠道白名单。 |
106
- | 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
107
- | 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
108
- | 失败冷却 | `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED`、`OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 控制失败渠道、凭证或已识别请求方式是否临时移出路由池。 |
109
- | 上游超时 | `IMAGE_UPSTREAM_TIMEOUT_MS`、`IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS`、`IMAGE_UPSTREAM_MAX_RETRIES` | 默认按长耗时图片请求处理,SDK 自动重试默认关闭。 |
110
- | 日志窗口 | `APP_LOG_LEVEL`、`APP_LOG_MAX_ENTRIES` | 控制服务端日志等级和 Agent 诊断可回溯窗口。 |
111
 
112
  多渠道示例:
113
 
@@ -154,19 +154,19 @@ Agent API 是机器接口,不是自治 Agent 平台。自动化客户端应先
154
 
155
  常用入口:
156
 
157
- | 接口 | 用途 |
158
- | --- | --- |
159
- | `GET /api/agent/capabilities` | 查询模型、限制、认证方式和路由规则。 |
160
- | `GET /api/agent/openapi.json` | 获取 OpenAPI 描述。 |
161
- | `POST /api/agent/images/generate` | JSON 文生图(显式 Agent / 诊断)。 |
162
- | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 mask。 |
163
- | `POST /api/agent/jobs/images/generate` | 创建文生图 job。 |
164
- | `GET /api/agent/jobs/{id}` | 查询 job 状态。 |
165
- | `GET /api/agent/jobs/{id}/result` | 读取成功 job 的标准图片响应。 |
166
- | `GET /api/agent/artifacts/{id}/content` | 下载产物图片。 |
167
- | `POST /api/agent/artifacts/{id}/share` | 为产物创建浏览器可访问的分享链接。 |
168
- | `POST /api/agent/diagnostics/page-requests` | 批量读取页面请求的脱敏日志诊断摘要。 |
169
- | `GET /api/agent/diagnostics/requests` | 按 request id 或幂等键查询诊断。 |
170
 
171
  生成示例:
172
 
@@ -284,7 +284,7 @@ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
284
  - `GPT_IMAGE_AGENT_TOKEN` 只用于 `/api/agent/*`;页面 SSE `/api/images` 可能还需要 `GPT_IMAGE_APP_PASSWORD_HASH`。
285
  - 需要给用户浏览器访问图片时,不要公开原始 artifact 下载 URL。使用 `POST /api/agent/artifacts/{id}/share` 或 skill 的 `--share` 创建分享链接;创建动作需要 Agent 鉴权,返回的 `share_url` 使用随机分享 token,公开分享可直接打开,设置访问码的分享需要用户在分享页输入访问码。
286
  - dry-run 只验证本地请求构造;`verification_scope.mode=local_planning_only` 不是远端已可执行。需要只读读取远端 capabilities 和 runtime 时加 `--check-remote`,输出 `verification_scope.mode=remote_contract_and_local_planning`。远端合同检查用 `--contract-check`,真实执行必须加 `--allow-billable`。
287
- - 多张真实任务优先用 `batch-images.mjs`、`--manifest`、`--resume` 和 `--dimension-check`;不要手动并行启动多个单张脚本。需要并发时先看 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`。
288
  - 选择 `responses-image-generation` 或兼容别名 `responses` 时,`partial_images` 必须优先按 `partial_images_by_backend["responses-image-generation"]` 校验,不能套用 Matsca Images API 的范围。
289
  - 页面 SSE 返回 503 或断流时,先用诊断脚本读取结构化摘要,再用新的幂等键显式选择备用路径。`page_sse_supported=true` 只是声明支持,不代表实测一定成功。Agent edit 输出格式和尺寸可能与页面 SSE 不完全一致,尺寸敏感任务必须重新校验或用 `--dimension-check`。
290
  - 排查环境配置时优先运行 `npm run env:summary`,不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。
@@ -320,11 +320,11 @@ npm run docker:cleanup-fixtures
320
 
321
  常见部署模式:
322
 
323
- | 模式 | 命令或配置 | 适用场景 |
324
- | --- | --- | --- |
325
- | SQLite | `docker-compose.yml` | 本地单实例和长期本地服务。 |
326
- | Memory | `docker-compose.yml` + `docker-compose.memory.yml` | Hugging Face Space 免费层或临时演示。 |
327
- | PostgreSQL | `docker-compose.yml` + `docker-compose.postgres.yml` | 高并发、多实例或集中状态库。 |
328
 
329
  图片默认保存在:
330
 
@@ -336,35 +336,35 @@ Hugging Face Space 免费层部署见 [docs/deployment/huggingface-space-free.md
336
 
337
  ## 常用命令
338
 
339
- | 命令 | 用途 |
340
- | --- | --- |
341
- | `npm run dev` | 启动本地开发服务,端口 `4783`。 |
342
- | `npm run build` | 执行生产构建。 |
343
- | `npm run start` | 启动 standalone 生产服务,需先执行 `npm run build`。 |
344
- | `npm test` | 运行全量测试。 |
345
- | `npm run test:postgres` | 运行真实 PostgreSQL gate。 |
346
- | `npm run lint` | 检查 `src/` 代码。 |
347
- | `npm run lint:scripts` | 检查仓库脚本和 skill 脚本语法。 |
348
- | `npm run version:check` | 检查版本、README badge 和 CHANGELOG 口径。 |
349
- | `npm run verify` | 运行提交前基线。 |
350
- | `npm run docker:cleanup-fixtures` | 清理遗留的整仓挂载 Docker fixture 容器。 |
351
- | `npm run first-run` | 首次配置就绪检查,默认中文摘要;加 `-- --json` 输出机器可读 JSON。 |
352
- | `npm run status` | 只读查看 git、Node、部署目标、Agent 摘要和真实 smoke 配置状态;不执行计费图片请求。 |
353
- | `npm run doctor` | 运行本机和部署诊断。 |
354
- | `npm run agent:doctor` | 非计费 Agent 分层诊断;支 `-- --base-url <url>`。 |
355
- | `npm run deploy:space` | 上传干净 git HEAD 到固定 HF Space。 |
356
 
357
  真实上游 smoke 默认不会触发计费;`npm run status` 只报告 `configuration_complete` 和 `smoke_state=not_run_by_status` 等配置口径。需要真实生图验证时必须显式传入 `--allow-billable`。
358
 
359
  ## 常见问题
360
 
361
- | 问题 | 处理 |
362
- | --- | --- |
363
- | 未检测到 Node.js | 安装 Node.js >=20.9.0。 |
364
- | 依赖安装失败 | 检查 npm 网络后重新执行 `npm install`。 |
365
- | API 返回 HTML | API URL 填成了网页或管理后台;应填写 OpenAI 兼容 `/v1` 根地址。 |
366
  | 提示需要 API Key | 在 `.env.local` 写入 `OPENAI_API_KEY`,或在页面 `API 设置` 中填写。 |
367
- | 端口被占用 | 默认端口是 `4783`,检查旧进程或旧容器。 |
368
 
369
  ## 文档
370
 
 
91
 
92
  完整配置说明见 [.env.example](./.env.example)。README 只列常用项。
93
 
94
+ | 场景 | 变量 | 说明 |
95
+ | ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
96
+ | 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
97
+ | 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
98
+ | Agent 鉴权 | `AGENT_API_TOKEN` | 设置后 `/api/agent/*` 需要 Bearer token。 |
99
+ | Agent 公开地址 | `AGENT_PUBLIC_BASE_URL` | OpenAPI `servers[0].url` 和 Agent artifact 分享外链使用的公网 base URL。 |
100
+ | 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | `fs` 或 `indexeddb`。Docker 默认使用 `fs`。 |
101
+ | Agent 状态 | `AGENT_STATE_BACKEND` | `memory`、`sqlite` 或 `postgres`。Docker 默认使用 `sqlite`。 |
102
+ | 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
103
+ | Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
104
+ | 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
105
+ | 渠道请求方式 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 可选。声明全局或单渠道可用方式;未配置时默认只允许 `images-non-stream`。每个渠道只保留真实 smoke 通过且能被本服务消费的模式,结果必须是内联 `b64_json`、Responses `result` 或同源 artifact URL。新上游若 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,就不要把 `responses-non-stream`、`responses-sse` 放进该渠道白名单。 |
106
+ | 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
107
+ | 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
108
+ | 失败冷却 | `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED`、`OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 控制失败渠道、凭证或已识别请求方式是否临时移出路由池。 |
109
+ | 上游超时 | `IMAGE_UPSTREAM_TIMEOUT_MS`、`IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS`、`IMAGE_UPSTREAM_MAX_RETRIES` | 默认按长耗时图片请求处理,SDK 自动重试默认关闭。 |
110
+ | 日志窗口 | `APP_LOG_LEVEL`、`APP_LOG_MAX_ENTRIES` | 控制服务端日志等级和 Agent 诊断可回溯窗口。 |
111
 
112
  多渠道示例:
113
 
 
154
 
155
  常用入口:
156
 
157
+ | 接口 | 用途 |
158
+ | ------------------------------------------- | ------------------------------------- |
159
+ | `GET /api/agent/capabilities` | 查询模型、限制、认证方式和路由规则。 |
160
+ | `GET /api/agent/openapi.json` | 获取 OpenAPI 描述。 |
161
+ | `POST /api/agent/images/generate` | JSON 文生图(显式 Agent / 诊断)。 |
162
+ | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 mask。 |
163
+ | `POST /api/agent/jobs/images/generate` | 创建文生图 job。 |
164
+ | `GET /api/agent/jobs/{id}` | 查询 job 状态。 |
165
+ | `GET /api/agent/jobs/{id}/result` | 读取成功 job 的标准图片响应。 |
166
+ | `GET /api/agent/artifacts/{id}/content` | 下载产物图片。 |
167
+ | `POST /api/agent/artifacts/{id}/share` | 为产物创建浏览器可访问的分享链接。 |
168
+ | `POST /api/agent/diagnostics/page-requests` | 批量读取页面请求的脱敏日志诊断摘要。 |
169
+ | `GET /api/agent/diagnostics/requests` | 按 request id 或幂等键查询诊断。 |
170
 
171
  生成示例:
172
 
 
284
  - `GPT_IMAGE_AGENT_TOKEN` 只用于 `/api/agent/*`;页面 SSE `/api/images` 可能还需要 `GPT_IMAGE_APP_PASSWORD_HASH`。
285
  - 需要给用户浏览器访问图片时,不要公开原始 artifact 下载 URL。使用 `POST /api/agent/artifacts/{id}/share` 或 skill 的 `--share` 创建分享链接;创建动作需要 Agent 鉴权,返回的 `share_url` 使用随机分享 token,公开分享可直接打开,设置访问码的分享需要用户在分享页输入访问码。
286
  - dry-run 只验证本地请求构造;`verification_scope.mode=local_planning_only` 不是远端已可执行。需要只读读取远端 capabilities 和 runtime 时加 `--check-remote`,输出 `verification_scope.mode=remote_contract_and_local_planning`。远端合同检查用 `--contract-check`,真实执行必须加 `--allow-billable`。
287
+ - 固定尺寸真实任务使用 `--dimension-check` 验收实际图片尺寸;单张生成、单张编辑和批量任务都会把尺寸偏差报告为结构化 `dimension_check_failed`,并保留产物 URL 与服务端选路摘要。多张真实任务优先用 `batch-images.mjs`、`--manifest`、`--resume` 和 `--dimension-check`;不要手动并行启动多个单张脚本。需要并发时先看 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`。
288
  - 选择 `responses-image-generation` 或兼容别名 `responses` 时,`partial_images` 必须优先按 `partial_images_by_backend["responses-image-generation"]` 校验,不能套用 Matsca Images API 的范围。
289
  - 页面 SSE 返回 503 或断流时,先用诊断脚本读取结构化摘要,再用新的幂等键显式选择备用路径。`page_sse_supported=true` 只是声明支持,不代表实测一定成功。Agent edit 输出格式和尺寸可能与页面 SSE 不完全一致,尺寸敏感任务必须重新校验或用 `--dimension-check`。
290
  - 排查环境配置时优先运行 `npm run env:summary`,不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。
 
320
 
321
  常见部署模式:
322
 
323
+ | 模式 | 命令或配置 | 适用场景 |
324
+ | ---------- | ---------------------------------------------------- | ------------------------------------- |
325
+ | SQLite | `docker-compose.yml` | 本地单实例和长期本地服务。 |
326
+ | Memory | `docker-compose.yml` + `docker-compose.memory.yml` | Hugging Face Space 免费层或临时演示。 |
327
+ | PostgreSQL | `docker-compose.yml` + `docker-compose.postgres.yml` | 高并发、多实例或集中状态库。 |
328
 
329
  图片默认保存在:
330
 
 
336
 
337
  ## 常用命令
338
 
339
+ | 命令 | 用途 |
340
+ | --------------------------------- | ------------------------------------------------------------------------------------------- |
341
+ | `npm run dev` | 启动本地开发服务,端口 `4783`。 |
342
+ | `npm run build` | 执行生产构建。 |
343
+ | `npm run start` | 启动 standalone 生产服务,需先执行 `npm run build`。 |
344
+ | `npm test` | 运行默认单元和契约测试;本地上游 final gate 单独运行 `npm run smoke:image-upstream-local`。 |
345
+ | `npm run test:postgres` | 运行真实 PostgreSQL gate。 |
346
+ | `npm run lint` | 检查 `src/` 代码。 |
347
+ | `npm run lint:scripts` | 检查仓库脚本和 skill 脚本语法。 |
348
+ | `npm run version:check` | 检查版本、README badge 和 CHANGELOG 口径。 |
349
+ | `npm run verify` | 运行提交前基线。 |
350
+ | `npm run docker:cleanup-fixtures` | 清理遗留的整仓挂载 Docker fixture 容器。 |
351
+ | `npm run first-run` | 首次配置就绪检查,默认中文摘要;加 `-- --json` 输出机器可读 JSON。 |
352
+ | `npm run status` | 只读查看 git、Node、部署目标、Agent 摘要和真实 smoke 配置状态;不执行计费图片请求。 |
353
+ | `npm run doctor` | 运行本机和部署诊断。 |
354
+ | `npm run agent:doctor` | 非计费 Agent 分层诊断;支��� `-- --base-url <url>`。 |
355
+ | `npm run deploy:space` | 上传干净 git HEAD 到固定 HF Space。 |
356
 
357
  真实上游 smoke 默认不会触发计费;`npm run status` 只报告 `configuration_complete` 和 `smoke_state=not_run_by_status` 等配置口径。需要真实生图验证时必须显式传入 `--allow-billable`。
358
 
359
  ## 常见问题
360
 
361
+ | 问题 | 处理 |
362
+ | ---------------- | ------------------------------------------------------------------- |
363
+ | 未检测到 Node.js | 安装 Node.js >=20.9.0。 |
364
+ | 依赖安装失败 | 检查 npm 网络后重新执行 `npm install`。 |
365
+ | API 返回 HTML | API URL 填成了网页或管理后台;应填写 OpenAI 兼容 `/v1` 根地址。 |
366
  | 提示需要 API Key | 在 `.env.local` 写入 `OPENAI_API_KEY`,或在页面 `API 设置` 中填写。 |
367
+ | 端口被占用 | 默认端口是 `4783`,检查旧进程或旧容器。 |
368
 
369
  ## 文档
370
 
public/hf-space-deploy-marker.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
  "schema_version": 1,
3
- "local_sha": "b908b02b1ca43bf88871a9ffb18a6bc9421263ad",
4
- "created_at": "2026-07-03T18:03:10.848Z",
5
- "deploy_id": "3662dde9-7453-4db4-872d-b86510c45633"
6
  }
 
1
  {
2
  "schema_version": 1,
3
+ "local_sha": "e9050df6c33f04cecaad6d1fcd2c7714a262bb12",
4
+ "created_at": "2026-07-06T10:09:57.902Z",
5
+ "deploy_id": "572b33fc-af8f-417f-bc6f-70cf33b54c06"
6
  }
scripts/agent-doctor.mjs CHANGED
@@ -1,16 +1,25 @@
1
  #!/usr/bin/env node
2
-
3
- import { fileURLToPath } from 'node:url';
4
-
5
- import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, redactBaseUrl, runCommand } from './command-center-utils.mjs';
6
  import {
7
  loadPrivateAgentEnvFile,
8
  resolvePlaygroundBaseUrl
9
  } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
10
  import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/lib/channel-request-mode-values.mjs';
 
 
 
 
 
 
 
 
 
11
 
12
- const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
13
- const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
 
 
 
 
14
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
15
  const ORCHESTRATION_GENERATE_SMOKE_NAME = 'orchestration_generate_1k';
16
  const AGENT_GENERATE_SMOKE_NAME = 'agent_generate_1k';
@@ -40,7 +49,8 @@ function parseArgs(argv) {
40
  const arg = argv[index];
41
  if (arg === '--help' || arg === '-h') parsed.help = true;
42
  else if (arg === '--allow-billable') parsed.allowBillable = true;
43
- else if (arg === '--timeout-ms') parsed.timeoutMs = readPositiveInteger(readOptionValue(argv, (index += 1), arg), '--timeout-ms');
 
44
  else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
45
  else if (arg === '--edit-image') parsed.editImage = readOptionValue(argv, (index += 1), arg);
46
  else throw new Error(`未知参数:${arg}`);
@@ -118,7 +128,8 @@ async function readJsonLayer(name, url, timeoutMs) {
118
  try {
119
  const response = await fetch(url, { headers: authHeaders(), signal: controller.signal });
120
  const text = await response.text();
121
- if (!response.ok) throw new Error(`${safePathname(url)} failed with HTTP ${response.status}${formatBodySnippet(text)}`);
 
122
  return { ok: true, body: text ? JSON.parse(text) : {} };
123
  } catch (error) {
124
  return { ok: false, error: error instanceof Error ? error.message : String(error), name };
@@ -425,11 +436,12 @@ function buildSummary({ capabilities, runtime, contract, smoke }) {
425
  }),
426
  responses_gpt2image_ready:
427
  capabilities.ok && runtime.ok
428
- ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true &&
429
- runtime.body?.responsesImageBackend?.enabled === true
430
  : false,
431
  responses_image_backend_declared_supported: capabilities.ok
432
- ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.supported === true
 
433
  : false,
434
  billable_smoke: smoke.skipped ? 'skipped' : smoke.ok ? 'ok' : 'failed'
435
  };
@@ -445,7 +457,8 @@ function buildRuntimeEnvironmentSummary({ capabilities, runtime }) {
445
  orchestration_endpoint: capabilities?.orchestration?.endpoint || null,
446
  orchestration_transport_selection: capabilities?.orchestration?.transport_selection || null,
447
  request_mode_control_policy: capabilities?.request_mode_controls?.agent_client_policy || null,
448
- request_mode_controls: runtime?.channelRouting?.requestModeControls || capabilities?.request_mode_controls || null,
 
449
  runtime_strategy: runtime?.channelRouting?.strategy || null,
450
  effective_request_modes: readRequestModeList(runtime?.channelRouting?.effectiveRequestModes),
451
  streaming_batch_enabled: runtime?.streamingBatch?.enabled === true,
@@ -483,18 +496,172 @@ function buildRequestModeSummary({ capabilities, runtime, smoke }) {
483
  const supported = readRequestModeList(capabilities?.supported?.request_modes);
484
  const configured = readRequestModeList(runtime?.channelRouting?.configuredRequestModes);
485
  const effective = readRequestModeList(runtime?.channelRouting?.effectiveRequestModes);
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  return {
487
  supported,
488
  configured,
489
  effective,
490
- admin_whitelist_by_channel: readCapabilitiesRequestModesByChannel(capabilities),
491
- effective_by_channel: readRuntimeRequestModesByChannel(runtime),
492
- smoke: Object.fromEntries(
493
- CHANNEL_REQUEST_MODES.map((mode) => [mode, summarizeRequestModeSmoke(smoke, mode)])
494
- )
 
 
495
  };
496
  }
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
  function summarizeRequestModeSmoke(smoke, mode) {
499
  const checks = CHANNEL_REQUEST_MODE_SMOKE_CASES[mode] || [];
500
  if (checks.length === 0) {
@@ -516,30 +683,43 @@ function readCapabilitiesRequestModesByChannel(body) {
516
  const channels = Array.isArray(body?.upstream_request_headers?.channels)
517
  ? body.upstream_request_headers.channels
518
  : [];
519
- return channels.map((channel) => ({
520
- channel_id: String(channel?.id || ''),
521
- request_modes: readRequestModeList(channel?.request_modes)
522
- })).filter((channel) => channel.channel_id);
 
 
523
  }
524
 
525
  function readRuntimeRequestModesByChannel(body) {
526
  const channels = Array.isArray(body?.channelRouting?.effectiveRequestModesByChannel)
527
  ? body.channelRouting.effectiveRequestModesByChannel
528
  : [];
529
- return channels.map((channel) => ({
530
- channel_id: String(channel?.channelId || ''),
531
- request_modes: readRequestModeList(channel?.requestModes)
532
- })).filter((channel) => channel.channel_id);
 
 
533
  }
534
 
535
  function readRequestModeList(value) {
536
  if (!Array.isArray(value)) return [];
537
- return CHANNEL_REQUEST_MODES.filter((mode) => value.includes(mode));
 
 
 
 
 
 
 
 
538
  }
539
 
540
  function authHeaders() {
541
  if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
542
- if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
 
543
  return {};
544
  }
545
 
@@ -557,7 +737,9 @@ function readPositiveInteger(value, name) {
557
  }
558
 
559
  function normalizeBaseUrl(value) {
560
- const normalized = String(value || '').trim().replace(/\/+$/, '');
 
 
561
  const parsed = new URL(normalized);
562
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
563
  throw new Error('base URL must use http or https.');
 
1
  #!/usr/bin/env node
 
 
 
 
2
  import {
3
  loadPrivateAgentEnvFile,
4
  resolvePlaygroundBaseUrl
5
  } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
6
  import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/lib/channel-request-mode-values.mjs';
7
+ import {
8
+ isMainModule,
9
+ parseJsonPayload,
10
+ pickFailureOutput,
11
+ printJson,
12
+ redactBaseUrl,
13
+ runCommand
14
+ } from './command-center-utils.mjs';
15
+ import { fileURLToPath } from 'node:url';
16
 
17
+ const GENERATE_SCRIPT = fileURLToPath(
18
+ new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url)
19
+ );
20
+ const EDIT_SCRIPT = fileURLToPath(
21
+ new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url)
22
+ );
23
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
24
  const ORCHESTRATION_GENERATE_SMOKE_NAME = 'orchestration_generate_1k';
25
  const AGENT_GENERATE_SMOKE_NAME = 'agent_generate_1k';
 
49
  const arg = argv[index];
50
  if (arg === '--help' || arg === '-h') parsed.help = true;
51
  else if (arg === '--allow-billable') parsed.allowBillable = true;
52
+ else if (arg === '--timeout-ms')
53
+ parsed.timeoutMs = readPositiveInteger(readOptionValue(argv, (index += 1), arg), '--timeout-ms');
54
  else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
55
  else if (arg === '--edit-image') parsed.editImage = readOptionValue(argv, (index += 1), arg);
56
  else throw new Error(`未知参数:${arg}`);
 
128
  try {
129
  const response = await fetch(url, { headers: authHeaders(), signal: controller.signal });
130
  const text = await response.text();
131
+ if (!response.ok)
132
+ throw new Error(`${safePathname(url)} failed with HTTP ${response.status}${formatBodySnippet(text)}`);
133
  return { ok: true, body: text ? JSON.parse(text) : {} };
134
  } catch (error) {
135
  return { ok: false, error: error instanceof Error ? error.message : String(error), name };
 
436
  }),
437
  responses_gpt2image_ready:
438
  capabilities.ok && runtime.ok
439
+ ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled ===
440
+ true && runtime.body?.responsesImageBackend?.enabled === true
441
  : false,
442
  responses_image_backend_declared_supported: capabilities.ok
443
+ ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.supported ===
444
+ true
445
  : false,
446
  billable_smoke: smoke.skipped ? 'skipped' : smoke.ok ? 'ok' : 'failed'
447
  };
 
457
  orchestration_endpoint: capabilities?.orchestration?.endpoint || null,
458
  orchestration_transport_selection: capabilities?.orchestration?.transport_selection || null,
459
  request_mode_control_policy: capabilities?.request_mode_controls?.agent_client_policy || null,
460
+ request_mode_controls:
461
+ runtime?.channelRouting?.requestModeControls || capabilities?.request_mode_controls || null,
462
  runtime_strategy: runtime?.channelRouting?.strategy || null,
463
  effective_request_modes: readRequestModeList(runtime?.channelRouting?.effectiveRequestModes),
464
  streaming_batch_enabled: runtime?.streamingBatch?.enabled === true,
 
496
  const supported = readRequestModeList(capabilities?.supported?.request_modes);
497
  const configured = readRequestModeList(runtime?.channelRouting?.configuredRequestModes);
498
  const effective = readRequestModeList(runtime?.channelRouting?.effectiveRequestModes);
499
+ const adminWhitelistByChannel = readCapabilitiesRequestModesByChannel(capabilities);
500
+ const effectiveByChannel = readRuntimeRequestModesByChannel(runtime);
501
+ const smokeSummary = Object.fromEntries(
502
+ CHANNEL_REQUEST_MODES.map((mode) => [mode, summarizeRequestModeSmoke(smoke, mode)])
503
+ );
504
+ const gaps = buildRequestModeGaps({
505
+ supported,
506
+ configured,
507
+ effective,
508
+ adminWhitelistByChannel,
509
+ effectiveByChannel,
510
+ smoke: smokeSummary
511
+ });
512
  return {
513
  supported,
514
  configured,
515
  effective,
516
+ admin_whitelist_by_channel: adminWhitelistByChannel,
517
+ effective_by_channel: effectiveByChannel,
518
+ smoke: smokeSummary,
519
+ gaps,
520
+ suggested_channel_env_key: 'OPENAI_CHANNEL_N_REQUEST_MODES',
521
+ suggested_effective_value: buildSuggestedEffectiveRequestModes({ effective, smoke: smokeSummary }).join(','),
522
+ next_action: buildRequestModeNextAction({ effective, gaps, smoke: smokeSummary })
523
  };
524
  }
525
 
526
+ function buildSuggestedEffectiveRequestModes({ effective, smoke }) {
527
+ if (Object.values(smoke).every((value) => value.state === 'skipped')) return effective;
528
+ return effective.filter((mode) => smoke[mode]?.state === 'passed');
529
+ }
530
+
531
+ function buildRequestModeGaps({
532
+ supported,
533
+ configured,
534
+ effective,
535
+ adminWhitelistByChannel,
536
+ effectiveByChannel,
537
+ smoke
538
+ }) {
539
+ const supportedModes = Array.isArray(supported) ? supported : [];
540
+ const configuredModes = Array.isArray(configured) ? configured : [];
541
+ const effectiveModes = Array.isArray(effective) ? effective : [];
542
+ const adminChannels = normalizeRequestModeChannelEntries(adminWhitelistByChannel);
543
+ const effectiveChannels = normalizeRequestModeChannelEntries(effectiveByChannel);
544
+ const gaps = [];
545
+ if (effectiveModes.length === 0) {
546
+ gaps.push({
547
+ code: 'no_effective_request_modes',
548
+ severity: 'critical',
549
+ message: '当前服务没有可用上游请求方式;检查 OPENAI_CHANNEL_N_REQUEST_MODES、渠道健康和 API key。'
550
+ });
551
+ }
552
+ const unrecognizedModes = collectUnrecognizedRequestModes(
553
+ supportedModes,
554
+ configuredModes,
555
+ effectiveModes,
556
+ adminChannels.flatMap((channel) => channel.request_modes),
557
+ effectiveChannels.flatMap((channel) => channel.request_modes)
558
+ );
559
+ if (unrecognizedModes.length > 0) {
560
+ gaps.push({
561
+ code: 'unrecognized_request_modes',
562
+ severity: 'warning',
563
+ request_modes: unrecognizedModes,
564
+ message: '服务返回了当前 Agent 未识别的 request mode;升级 skill 或确认服务端模式名称。'
565
+ });
566
+ }
567
+ const unsupportedConfigured = configuredModes.filter((mode) => !supportedModes.includes(mode));
568
+ if (unsupportedConfigured.length > 0) {
569
+ gaps.push({
570
+ code: 'configured_unsupported_request_modes',
571
+ severity: 'warning',
572
+ request_modes: unsupportedConfigured,
573
+ message: '配置中包含服务不支持的 request mode。'
574
+ });
575
+ }
576
+ const configuredButIneffective = configuredModes.filter(
577
+ (mode) => supportedModes.includes(mode) && !effectiveModes.includes(mode)
578
+ );
579
+ if (configuredButIneffective.length > 0) {
580
+ gaps.push({
581
+ code: 'configured_request_modes_not_effective',
582
+ severity: 'warning',
583
+ request_modes: configuredButIneffective,
584
+ message: '部分已配置 request mode 没有在 runtime 生效;检查对应渠道 key、健康状态和白名单。'
585
+ });
586
+ }
587
+ const effectiveByChannelMap = new Map(
588
+ effectiveChannels.map((channel) => [channel.channel_id, channel.request_modes])
589
+ );
590
+ const channelsWithoutModes = [
591
+ ...adminChannels
592
+ .filter((channel) => channel.request_modes.length > 0)
593
+ .filter((channel) => (effectiveByChannelMap.get(channel.channel_id) || []).length === 0),
594
+ ...effectiveChannels.filter((channel) => channel.request_modes.length === 0)
595
+ ];
596
+ const uniqueChannelsWithoutModes = [
597
+ ...new Map(channelsWithoutModes.map((channel) => [channel.channel_id, channel])).values()
598
+ ];
599
+ if (uniqueChannelsWithoutModes.length > 0) {
600
+ gaps.push({
601
+ code: 'channels_without_effective_request_modes',
602
+ severity: 'warning',
603
+ channel_ids: uniqueChannelsWithoutModes.map((channel) => channel.channel_id),
604
+ message: '部分渠道没有生效 request mode。'
605
+ });
606
+ }
607
+ const failedSmokeModes = Object.entries(smoke)
608
+ .filter(([, value]) => value.state === 'failed')
609
+ .map(([mode]) => mode);
610
+ if (failedSmokeModes.length > 0) {
611
+ gaps.push({
612
+ code: 'request_mode_smoke_failed',
613
+ severity: 'critical',
614
+ request_modes: failedSmokeModes,
615
+ message: '真实 smoke 显示部分 request mode 不可用;不要写入渠道白名单。'
616
+ });
617
+ }
618
+ return gaps;
619
+ }
620
+
621
+ function normalizeRequestModeChannelEntries(value) {
622
+ if (!Array.isArray(value)) return [];
623
+ return value
624
+ .map((channel) => ({
625
+ channel_id: String(channel?.channel_id || ''),
626
+ request_modes: readRequestModeList(channel?.request_modes)
627
+ }))
628
+ .filter((channel) => channel.channel_id);
629
+ }
630
+
631
+ function collectUnrecognizedRequestModes(...modeLists) {
632
+ const unrecognized = [];
633
+ const seen = new Set();
634
+ for (const modes of modeLists) {
635
+ if (!Array.isArray(modes)) continue;
636
+ for (const mode of modes) {
637
+ if (CHANNEL_REQUEST_MODES.includes(mode) || seen.has(mode)) continue;
638
+ seen.add(mode);
639
+ unrecognized.push(mode);
640
+ }
641
+ }
642
+ return unrecognized;
643
+ }
644
+
645
+ function buildRequestModeNextAction({ effective, gaps, smoke }) {
646
+ if (effective.length === 0) {
647
+ return '先用 probe-upstream-image.mjs 或 npm run smoke:image-upstream-real -- --allow-billable 探测上游,再配置 OPENAI_CHANNEL_N_REQUEST_MODES。';
648
+ }
649
+ if (gaps.some((gap) => gap.code === 'request_mode_smoke_failed')) {
650
+ return '从 OPENAI_CHANNEL_N_REQUEST_MODES 移除 smoke 失败的 request mode,只保留通过的模式。';
651
+ }
652
+ if (
653
+ gaps.some((gap) =>
654
+ ['configured_request_modes_not_effective', 'channels_without_effective_request_modes'].includes(gap.code)
655
+ )
656
+ ) {
657
+ return '先修正未生效的渠道 request mode、API key 或健康状态,再用 --allow-billable smoke 验证真实可用性。';
658
+ }
659
+ if (Object.values(smoke).every((value) => value.state === 'skipped')) {
660
+ return '当前只验证了配置可见性;真实渠道可用性需要显式 --allow-billable smoke。';
661
+ }
662
+ return '当前 request mode 配置可见;以 effective 和 smoke passed 的交集作为管理员白名单候选。';
663
+ }
664
+
665
  function summarizeRequestModeSmoke(smoke, mode) {
666
  const checks = CHANNEL_REQUEST_MODE_SMOKE_CASES[mode] || [];
667
  if (checks.length === 0) {
 
683
  const channels = Array.isArray(body?.upstream_request_headers?.channels)
684
  ? body.upstream_request_headers.channels
685
  : [];
686
+ return channels
687
+ .map((channel) => ({
688
+ channel_id: String(channel?.id || ''),
689
+ request_modes: readRequestModeList(channel?.request_modes)
690
+ }))
691
+ .filter((channel) => channel.channel_id);
692
  }
693
 
694
  function readRuntimeRequestModesByChannel(body) {
695
  const channels = Array.isArray(body?.channelRouting?.effectiveRequestModesByChannel)
696
  ? body.channelRouting.effectiveRequestModesByChannel
697
  : [];
698
+ return channels
699
+ .map((channel) => ({
700
+ channel_id: String(channel?.channelId || ''),
701
+ request_modes: readRequestModeList(channel?.requestModes)
702
+ }))
703
+ .filter((channel) => channel.channel_id);
704
  }
705
 
706
  function readRequestModeList(value) {
707
  if (!Array.isArray(value)) return [];
708
+ const modes = [];
709
+ const seen = new Set();
710
+ for (const item of value) {
711
+ const mode = typeof item === 'string' ? item.trim() : '';
712
+ if (!mode || seen.has(mode)) continue;
713
+ seen.add(mode);
714
+ modes.push(mode);
715
+ }
716
+ return modes;
717
  }
718
 
719
  function authHeaders() {
720
  if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
721
+ if (process.env.GPT_IMAGE_APP_PASSWORD_HASH)
722
+ return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
723
  return {};
724
  }
725
 
 
737
  }
738
 
739
  function normalizeBaseUrl(value) {
740
+ const normalized = String(value || '')
741
+ .trim()
742
+ .replace(/\/+$/, '');
743
  const parsed = new URL(normalized);
744
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
745
  throw new Error('base URL must use http or https.');
scripts/agent-skill-scripts.test.mjs CHANGED
The diff for this file is too large to render. See raw diff
 
scripts/command-center.test.mjs CHANGED
@@ -1,13 +1,4 @@
1
- import assert from 'node:assert/strict';
2
- import { spawn } from 'node:child_process';
3
- import { createServer } from 'node:http';
4
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import { describe, it } from 'node:test';
8
-
9
  import { buildAgentDoctorArgs, buildAgentDoctorContractArgs } from './agent-doctor.mjs';
10
- import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
11
  import {
12
  buildAbsentReport,
13
  buildSkippedReport,
@@ -15,6 +6,8 @@ import {
15
  parseDockerInspectContainer,
16
  summarizeDockerMounts
17
  } from './cleanup-docker-fixtures.mjs';
 
 
18
  import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
19
  import {
20
  buildAdminCommands,
@@ -24,8 +17,14 @@ import {
24
  readStatusEnvFromFiles,
25
  readRemoteStatusFromResult
26
  } from './status.mjs';
27
- import { assertLocalProbeMatchesMode, buildDockerComposeArgs, buildDockerComposeEnv } from './deploy-local.mjs';
28
  import { buildVerifyPlan } from './verify.mjs';
 
 
 
 
 
 
 
29
 
30
  describe('Command center scripts', () => {
31
  it('exposes a small stable administrator command set', () => {
@@ -600,9 +599,18 @@ describe('Command center scripts', () => {
600
  has_password_hash: false
601
  });
602
  assert.equal(report.service.ok, true);
603
- assert.equal(report.checks.find((check) => check.name === 'agent_auth_available_to_process').ok, true);
604
- assert.equal(report.checks.find((check) => check.name === 'page_sse_auth_available_to_process').ok, false);
605
- assert.equal(report.checks.find((check) => check.name === 'page_sse_auth_available_to_process').skipped, false);
 
 
 
 
 
 
 
 
 
606
  assert.equal(
607
  report.checks.find((check) => check.name === 'page_sse_auth_available_to_process')
608
  .auth_in_private_env_file,
@@ -612,7 +620,10 @@ describe('Command center scripts', () => {
612
  assert.equal(report.service.capabilities.page_sse_auth_form_field, 'passwordHash');
613
  assert.equal(report.service.capabilities.page_sse_declared_supported, true);
614
  assert.equal(report.service.capabilities.page_sse_real_smoke, 'not_run_by_first_run');
615
- assert.equal(report.service.capabilities.responses_image_backend_real_smoke, 'not_run_by_first_run');
 
 
 
616
  assert.deepEqual(report.service.capabilities.page_sse_real_smoke_status, {
617
  state: 'not_run',
618
  billable: false,
@@ -624,7 +635,10 @@ describe('Command center scripts', () => {
624
  reason: 'first-run is non-billable and does not call Responses image generation'
625
  });
626
  assert.match(formatFirstRunText(report), /页面 SSE:声明支持,实测=未执行真实 smoke/);
627
- assert.match(formatFirstRunText(report), /Responses 后端:声明未支持,启用=否,实测=未执行真实 smoke/);
 
 
 
628
  assert.equal(
629
  report.checks.find((check) => check.name === 'agent_auth_available_to_process')
630
  .auth_in_private_env_file,
@@ -777,7 +791,10 @@ describe('Command center scripts', () => {
777
  );
778
 
779
  assert.equal(report.service_base_url, baseUrl);
780
- assert.equal(report.checks.find((check) => check.name === 'agent_auth_available_to_process').skipped, false);
 
 
 
781
  assert.equal(report.checks.find((check) => check.name === 'service_reachable').status, 401);
782
  assert.equal('runtime' in report.service, false);
783
  assert.match(JSON.stringify(report.next_actions), /GPT_IMAGE_AGENT_TOKEN/);
@@ -824,6 +841,7 @@ describe('Command center scripts', () => {
824
  request_modes: [
825
  'images-non-stream',
826
  'images-sse',
 
827
  'responses-non-stream',
828
  'responses-sse'
829
  ],
@@ -960,11 +978,46 @@ describe('Command center scripts', () => {
960
  assert.deepEqual(body.summary.request_modes.supported, [
961
  'images-non-stream',
962
  'images-sse',
 
963
  'responses-non-stream',
964
  'responses-sse'
965
  ]);
966
- assert.deepEqual(body.summary.request_modes.configured, ['images-non-stream', 'images-sse', 'responses-sse']);
 
 
 
 
967
  assert.deepEqual(body.summary.request_modes.effective, ['images-non-stream', 'images-sse']);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
  assert.deepEqual(body.summary.request_modes.smoke['responses-non-stream'].checks, [
969
  'responses_agent_generate_1k'
970
  ]);
@@ -1009,17 +1062,24 @@ describe('Command center scripts', () => {
1009
  assert.deepEqual(body.layers.find((layer) => layer.name === 'capabilities').request_modes_supported, [
1010
  'images-non-stream',
1011
  'images-sse',
 
1012
  'responses-non-stream',
1013
  'responses-sse'
1014
  ]);
1015
- assert.deepEqual(body.layers.find((layer) => layer.name === 'runtime_backend').effective_request_modes, [
1016
- 'images-non-stream',
1017
- 'images-sse'
1018
- ]);
1019
- assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_declared_supported, true);
 
 
 
1020
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_required, true);
1021
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_ready, false);
1022
- assert.equal(body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').declared_supported, true);
 
 
 
1023
  assert.match(
1024
  body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').real_smoke_gate,
1025
  /gpt2image-responses-sse/
@@ -1271,6 +1331,7 @@ describe('Command center scripts', () => {
1271
  agent_jobs: { supported: true },
1272
  routing_rules: {},
1273
  supported: {
 
1274
  image_backend_requirements: {
1275
  'responses-image-generation': {
1276
  supported: true,
@@ -1278,6 +1339,18 @@ describe('Command center scripts', () => {
1278
  missing_env: []
1279
  }
1280
  }
 
 
 
 
 
 
 
 
 
 
 
 
1281
  }
1282
  })
1283
  );
@@ -1292,7 +1365,21 @@ describe('Command center scripts', () => {
1292
  unavailableMarkScope: 'channel+backend+strategy+operation'
1293
  },
1294
  streamingBatch: { enabled: true },
1295
- responsesImageBackend: { enabled: true, mode: 'experimental' }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1296
  })
1297
  );
1298
  return;
@@ -1405,6 +1492,24 @@ describe('Command center scripts', () => {
1405
  assert.equal(body.summary.request_modes.smoke['images-non-stream'].state, 'passed');
1406
  assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'passed');
1407
  assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'failed');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1408
  assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
1409
  const pageSseOutput = body.layers
1410
  .find((layer) => layer.name === 'billable_smoke')
@@ -1467,7 +1572,10 @@ describe('Command center scripts', () => {
1467
  });
1468
 
1469
  it('preserves invalid URL fetch errors while building timeout diagnostics', async () => {
1470
- await assert.rejects(() => fetchJsonWithTimeout('not a url', { timeoutMs: 20 }), /Failed to parse URL|Invalid URL/i);
 
 
 
1471
  });
1472
 
1473
  it('includes a response snippet when HTTP probes return non-JSON bodies', async () => {
 
 
 
 
 
 
 
 
 
1
  import { buildAgentDoctorArgs, buildAgentDoctorContractArgs } from './agent-doctor.mjs';
 
2
  import {
3
  buildAbsentReport,
4
  buildSkippedReport,
 
6
  parseDockerInspectContainer,
7
  summarizeDockerMounts
8
  } from './cleanup-docker-fixtures.mjs';
9
+ import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
10
+ import { assertLocalProbeMatchesMode, buildDockerComposeArgs, buildDockerComposeEnv } from './deploy-local.mjs';
11
  import { buildFirstRunReport, formatFirstRunText } from './first-run.mjs';
12
  import {
13
  buildAdminCommands,
 
17
  readStatusEnvFromFiles,
18
  readRemoteStatusFromResult
19
  } from './status.mjs';
 
20
  import { buildVerifyPlan } from './verify.mjs';
21
+ import assert from 'node:assert/strict';
22
+ import { spawn } from 'node:child_process';
23
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
24
+ import { createServer } from 'node:http';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { describe, it } from 'node:test';
28
 
29
  describe('Command center scripts', () => {
30
  it('exposes a small stable administrator command set', () => {
 
599
  has_password_hash: false
600
  });
601
  assert.equal(report.service.ok, true);
602
+ assert.equal(
603
+ report.checks.find((check) => check.name === 'agent_auth_available_to_process').ok,
604
+ true
605
+ );
606
+ assert.equal(
607
+ report.checks.find((check) => check.name === 'page_sse_auth_available_to_process').ok,
608
+ false
609
+ );
610
+ assert.equal(
611
+ report.checks.find((check) => check.name === 'page_sse_auth_available_to_process').skipped,
612
+ false
613
+ );
614
  assert.equal(
615
  report.checks.find((check) => check.name === 'page_sse_auth_available_to_process')
616
  .auth_in_private_env_file,
 
620
  assert.equal(report.service.capabilities.page_sse_auth_form_field, 'passwordHash');
621
  assert.equal(report.service.capabilities.page_sse_declared_supported, true);
622
  assert.equal(report.service.capabilities.page_sse_real_smoke, 'not_run_by_first_run');
623
+ assert.equal(
624
+ report.service.capabilities.responses_image_backend_real_smoke,
625
+ 'not_run_by_first_run'
626
+ );
627
  assert.deepEqual(report.service.capabilities.page_sse_real_smoke_status, {
628
  state: 'not_run',
629
  billable: false,
 
635
  reason: 'first-run is non-billable and does not call Responses image generation'
636
  });
637
  assert.match(formatFirstRunText(report), /页面 SSE:声明支持,实测=未执行真实 smoke/);
638
+ assert.match(
639
+ formatFirstRunText(report),
640
+ /Responses 后端:声明未支持,启用=否,实测=未执行真实 smoke/
641
+ );
642
  assert.equal(
643
  report.checks.find((check) => check.name === 'agent_auth_available_to_process')
644
  .auth_in_private_env_file,
 
791
  );
792
 
793
  assert.equal(report.service_base_url, baseUrl);
794
+ assert.equal(
795
+ report.checks.find((check) => check.name === 'agent_auth_available_to_process').skipped,
796
+ false
797
+ );
798
  assert.equal(report.checks.find((check) => check.name === 'service_reachable').status, 401);
799
  assert.equal('runtime' in report.service, false);
800
  assert.match(JSON.stringify(report.next_actions), /GPT_IMAGE_AGENT_TOKEN/);
 
841
  request_modes: [
842
  'images-non-stream',
843
  'images-sse',
844
+ 'images-json',
845
  'responses-non-stream',
846
  'responses-sse'
847
  ],
 
978
  assert.deepEqual(body.summary.request_modes.supported, [
979
  'images-non-stream',
980
  'images-sse',
981
+ 'images-json',
982
  'responses-non-stream',
983
  'responses-sse'
984
  ]);
985
+ assert.deepEqual(body.summary.request_modes.configured, [
986
+ 'images-non-stream',
987
+ 'images-sse',
988
+ 'responses-sse'
989
+ ]);
990
  assert.deepEqual(body.summary.request_modes.effective, ['images-non-stream', 'images-sse']);
991
+ assert.deepEqual(body.summary.request_modes.admin_whitelist_by_channel, [
992
+ { channel_id: 'images', request_modes: ['images-non-stream', 'images-sse'] },
993
+ { channel_id: 'responses', request_modes: ['responses-sse'] }
994
+ ]);
995
+ assert.deepEqual(body.summary.request_modes.effective_by_channel, [
996
+ { channel_id: 'images', request_modes: ['images-non-stream', 'images-sse'] }
997
+ ]);
998
+ assert.deepEqual(body.summary.request_modes.gaps, [
999
+ {
1000
+ code: 'unrecognized_request_modes',
1001
+ severity: 'warning',
1002
+ request_modes: ['images-json'],
1003
+ message: '服务返回了当前 Agent 未识别的 request mode;升级 skill 或确认服务端模式名称。'
1004
+ },
1005
+ {
1006
+ code: 'configured_request_modes_not_effective',
1007
+ severity: 'warning',
1008
+ request_modes: ['responses-sse'],
1009
+ message: '部分已配置 request mode 没有在 runtime 生效;检查对应渠道 key、健康状态和白名单。'
1010
+ },
1011
+ {
1012
+ code: 'channels_without_effective_request_modes',
1013
+ severity: 'warning',
1014
+ channel_ids: ['responses'],
1015
+ message: '部分渠道没有生效 request mode。'
1016
+ }
1017
+ ]);
1018
+ assert.equal(body.summary.request_modes.suggested_channel_env_key, 'OPENAI_CHANNEL_N_REQUEST_MODES');
1019
+ assert.equal(body.summary.request_modes.suggested_effective_value, 'images-non-stream,images-sse');
1020
+ assert.match(body.summary.request_modes.next_action, /先修正未生效的渠道 request mode/);
1021
  assert.deepEqual(body.summary.request_modes.smoke['responses-non-stream'].checks, [
1022
  'responses_agent_generate_1k'
1023
  ]);
 
1062
  assert.deepEqual(body.layers.find((layer) => layer.name === 'capabilities').request_modes_supported, [
1063
  'images-non-stream',
1064
  'images-sse',
1065
+ 'images-json',
1066
  'responses-non-stream',
1067
  'responses-sse'
1068
  ]);
1069
+ assert.deepEqual(
1070
+ body.layers.find((layer) => layer.name === 'runtime_backend').effective_request_modes,
1071
+ ['images-non-stream', 'images-sse']
1072
+ );
1073
+ assert.equal(
1074
+ body.layers.find((layer) => layer.name === 'capabilities').page_sse_declared_supported,
1075
+ true
1076
+ );
1077
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_required, true);
1078
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_ready, false);
1079
+ assert.equal(
1080
+ body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').declared_supported,
1081
+ true
1082
+ );
1083
  assert.match(
1084
  body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').real_smoke_gate,
1085
  /gpt2image-responses-sse/
 
1331
  agent_jobs: { supported: true },
1332
  routing_rules: {},
1333
  supported: {
1334
+ request_modes: ['images-non-stream', 'responses-non-stream', 'responses-sse'],
1335
  image_backend_requirements: {
1336
  'responses-image-generation': {
1337
  supported: true,
 
1339
  missing_env: []
1340
  }
1341
  }
1342
+ },
1343
+ upstream_request_headers: {
1344
+ channels: [
1345
+ {
1346
+ id: 'images',
1347
+ request_modes: ['images-non-stream']
1348
+ },
1349
+ {
1350
+ id: 'responses',
1351
+ request_modes: ['responses-non-stream', 'responses-sse']
1352
+ }
1353
+ ]
1354
  }
1355
  })
1356
  );
 
1365
  unavailableMarkScope: 'channel+backend+strategy+operation'
1366
  },
1367
  streamingBatch: { enabled: true },
1368
+ responsesImageBackend: { enabled: true, mode: 'experimental' },
1369
+ channelRouting: {
1370
+ configuredRequestModes: ['images-non-stream', 'responses-non-stream', 'responses-sse'],
1371
+ effectiveRequestModes: ['images-non-stream', 'responses-non-stream', 'responses-sse'],
1372
+ effectiveRequestModesByChannel: [
1373
+ {
1374
+ channelId: 'images',
1375
+ requestModes: ['images-non-stream']
1376
+ },
1377
+ {
1378
+ channelId: 'responses',
1379
+ requestModes: ['responses-non-stream', 'responses-sse']
1380
+ }
1381
+ ]
1382
+ }
1383
  })
1384
  );
1385
  return;
 
1492
  assert.equal(body.summary.request_modes.smoke['images-non-stream'].state, 'passed');
1493
  assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'passed');
1494
  assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'failed');
1495
+ assert.deepEqual(body.summary.request_modes.effective, [
1496
+ 'images-non-stream',
1497
+ 'responses-non-stream',
1498
+ 'responses-sse'
1499
+ ]);
1500
+ assert.equal(
1501
+ body.summary.request_modes.suggested_effective_value,
1502
+ 'images-non-stream,responses-non-stream'
1503
+ );
1504
+ assert.deepEqual(
1505
+ body.summary.request_modes.gaps.find((gap) => gap.code === 'request_mode_smoke_failed'),
1506
+ {
1507
+ code: 'request_mode_smoke_failed',
1508
+ severity: 'critical',
1509
+ request_modes: ['responses-sse'],
1510
+ message: '真实 smoke 显示部分 request mode 不可用;不要写入渠道白名单。'
1511
+ }
1512
+ );
1513
  assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
1514
  const pageSseOutput = body.layers
1515
  .find((layer) => layer.name === 'billable_smoke')
 
1572
  });
1573
 
1574
  it('preserves invalid URL fetch errors while building timeout diagnostics', async () => {
1575
+ await assert.rejects(
1576
+ () => fetchJsonWithTimeout('not a url', { timeoutMs: 20 }),
1577
+ /Failed to parse URL|Invalid URL/i
1578
+ );
1579
  });
1580
 
1581
  it('includes a response snippet when HTTP probes return non-JSON bodies', async () => {
scripts/dimension-check.test.mjs ADDED
@@ -0,0 +1,307 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ DimensionCheckError,
3
+ assertImageDimensions,
4
+ buildDimensionCheckFailureBody,
5
+ isDimensionCheckError,
6
+ parseExpectedDimensions,
7
+ sanitizeImageResponse
8
+ } from '../skills/gpt-image-playground-agent/scripts/lib/dimension-check.mjs';
9
+ import assert from 'node:assert/strict';
10
+ import { createServer } from 'node:http';
11
+ import { test } from 'node:test';
12
+
13
+ test('assertImageDimensions records dimensions for inline base64 images', async () => {
14
+ const response = {
15
+ images: [{ id: 'ok', b64_json: pngBase64(320, 240) }]
16
+ };
17
+
18
+ const checked = await assertImageDimensions({
19
+ response,
20
+ expected: { width: 320, height: 240 },
21
+ baseUrl: 'http://127.0.0.1:1',
22
+ authHeaders: {}
23
+ });
24
+
25
+ assert.deepEqual(checked.images[0].dimensions, { width: 320, height: 240 });
26
+ assert.equal(checked.images[0].b64_json, response.images[0].b64_json);
27
+ });
28
+
29
+ test('assertImageDimensions rejects mismatched dimensions with sanitized response', async () => {
30
+ await assert.rejects(
31
+ () =>
32
+ assertImageDimensions({
33
+ response: { images: [{ id: 'bad', b64_json: pngBase64(512, 512) }] },
34
+ expected: { width: 1024, height: 1024 },
35
+ baseUrl: 'http://127.0.0.1:1',
36
+ authHeaders: {}
37
+ }),
38
+ (error) => {
39
+ assert.equal(error instanceof DimensionCheckError, true);
40
+ assert.equal(error.code, 'dimension_check_failed');
41
+ assert.deepEqual(error.expectedDimensions, { width: 1024, height: 1024 });
42
+ assert.deepEqual(error.actualDimensions, { width: 512, height: 512 });
43
+ assert.equal(error.response.images[0].b64_json, undefined);
44
+ assert.equal(Object.prototype.hasOwnProperty.call(error.response.images[0], 'b64_json'), false);
45
+ assert.equal(error.response.images[0].b64_json_length, pngBase64(512, 512).length);
46
+ assert.deepEqual(error.response.images[0].dimensions, { width: 512, height: 512 });
47
+ return true;
48
+ }
49
+ );
50
+ });
51
+
52
+ test('assertImageDimensions rejects missing and empty image lists', async () => {
53
+ for (const response of [{}, { images: [] }]) {
54
+ await assert.rejects(
55
+ () =>
56
+ assertImageDimensions({
57
+ response,
58
+ expected: { width: 1024, height: 1024 },
59
+ baseUrl: 'http://127.0.0.1:1',
60
+ authHeaders: {}
61
+ }),
62
+ /响应中没有可验收的图片/
63
+ );
64
+ }
65
+ });
66
+
67
+ test('assertImageDimensions reads same-origin content URLs with auth headers', async () => {
68
+ const requests = [];
69
+ await withServer(
70
+ (request, response) => {
71
+ requests.push({ url: request.url, authorization: request.headers.authorization });
72
+ response.writeHead(200, { 'content-type': 'image/png' });
73
+ response.end(pngBuffer(640, 360));
74
+ },
75
+ async (baseUrl) => {
76
+ const checked = await assertImageDimensions({
77
+ response: { images: [{ id: 'url-image', content_url: '/artifact/url-image.png' }] },
78
+ expected: { width: 640, height: 360 },
79
+ baseUrl,
80
+ authHeaders: () => ({ Authorization: 'Bearer test-token' })
81
+ });
82
+
83
+ assert.deepEqual(checked.images[0].dimensions, { width: 640, height: 360 });
84
+ assert.equal(requests[0].url, '/artifact/url-image.png');
85
+ assert.equal(requests[0].authorization, 'Bearer test-token');
86
+ }
87
+ );
88
+ });
89
+
90
+ test('assertImageDimensions rejects path fields unless callers opt in explicitly', async () => {
91
+ await assert.rejects(
92
+ () =>
93
+ assertImageDimensions({
94
+ response: { images: [{ path: '/artifact/from-path.png' }] },
95
+ expected: { width: 1, height: 1 },
96
+ baseUrl: 'http://127.0.0.1:1',
97
+ authHeaders: {}
98
+ }),
99
+ /dimension-check 需要 b64_json 或 absolute_content_url 或 content_url/
100
+ );
101
+
102
+ await assert.rejects(
103
+ () =>
104
+ assertImageDimensions({
105
+ response: { images: [{}] },
106
+ expected: { width: 1, height: 1 },
107
+ baseUrl: 'http://127.0.0.1:1',
108
+ authHeaders: {},
109
+ readUrlFields: ['absolute_path', 'path']
110
+ }),
111
+ /dimension-check 需要 b64_json 或 absolute_path 或 path/
112
+ );
113
+ });
114
+
115
+ test('assertImageDimensions converts download failures and cross-origin URLs to dimension failures', async () => {
116
+ await withServer(
117
+ (_request, response) => {
118
+ response.writeHead(404, { 'content-type': 'application/json' });
119
+ response.end(JSON.stringify({ error: 'missing' }));
120
+ },
121
+ async (baseUrl) => {
122
+ await assert.rejects(
123
+ () =>
124
+ assertImageDimensions({
125
+ response: { images: [{ content_url: '/missing.png' }] },
126
+ expected: { width: 1, height: 1 },
127
+ baseUrl,
128
+ authHeaders: {}
129
+ }),
130
+ /下载产物失败,状态码 404/
131
+ );
132
+ }
133
+ );
134
+
135
+ await assert.rejects(
136
+ () =>
137
+ assertImageDimensions({
138
+ response: { images: [{ content_url: 'https://evil.example.test/image.png' }] },
139
+ expected: { width: 1, height: 1 },
140
+ baseUrl: 'https://space.example.test',
141
+ authHeaders: {}
142
+ }),
143
+ /不同 origin/
144
+ );
145
+
146
+ await assert.rejects(
147
+ () =>
148
+ assertImageDimensions({
149
+ response: { images: [{ absolute_content_url: 'https://evil.example.test/image.png' }] },
150
+ expected: { width: 1, height: 1 },
151
+ baseUrl: 'https://space.example.test',
152
+ authHeaders: {}
153
+ }),
154
+ /absolute_content_url 指向不同 origin/
155
+ );
156
+ });
157
+
158
+ test('assertImageDimensions times out stalled streamed downloads', async () => {
159
+ await withServer(
160
+ (_request, response) => {
161
+ response.writeHead(200, { 'content-type': 'image/png' });
162
+ response.write(pngBuffer(1, 1).subarray(0, 8));
163
+ },
164
+ async (baseUrl) => {
165
+ await assert.rejects(
166
+ () =>
167
+ assertImageDimensions({
168
+ response: { images: [{ content_url: '/stalled.png' }] },
169
+ expected: { width: 1, height: 1 },
170
+ baseUrl,
171
+ authHeaders: {},
172
+ timeoutMs: 25
173
+ }),
174
+ /下载产物超时,已等待 25ms/
175
+ );
176
+ }
177
+ );
178
+ });
179
+
180
+ test('assertImageDimensions falls back to default URL fields for invalid readUrlFields', async () => {
181
+ await withServer(
182
+ (_request, response) => {
183
+ response.writeHead(200, { 'content-type': 'image/png' });
184
+ response.end(pngBuffer(16, 9));
185
+ },
186
+ async (baseUrl) => {
187
+ const checked = await assertImageDimensions({
188
+ response: { images: [{ content_url: '/default-field.png' }] },
189
+ expected: { width: 16, height: 9 },
190
+ baseUrl,
191
+ authHeaders: {},
192
+ readUrlFields: null
193
+ });
194
+
195
+ assert.deepEqual(checked.images[0].dimensions, { width: 16, height: 9 });
196
+ }
197
+ );
198
+ });
199
+
200
+ test('assertImageDimensions enforces the max image byte limit', async () => {
201
+ await assert.rejects(
202
+ () =>
203
+ assertImageDimensions({
204
+ response: { images: [{ b64_json: pngBase64(1, 1) }] },
205
+ expected: { width: 1, height: 1 },
206
+ baseUrl: 'http://127.0.0.1:1',
207
+ authHeaders: {},
208
+ maxImageBytes: 8
209
+ }),
210
+ /图片数据超过 8 字节限制/
211
+ );
212
+
213
+ await withServer(
214
+ (_request, response) => {
215
+ response.writeHead(200, {
216
+ 'content-type': 'image/png',
217
+ 'content-length': String(pngBuffer(1, 1).length)
218
+ });
219
+ response.end(pngBuffer(1, 1));
220
+ },
221
+ async (baseUrl) => {
222
+ await assert.rejects(
223
+ () =>
224
+ assertImageDimensions({
225
+ response: { images: [{ content_url: '/large.png' }] },
226
+ expected: { width: 1, height: 1 },
227
+ baseUrl,
228
+ authHeaders: {},
229
+ maxImageBytes: 8
230
+ }),
231
+ /图片数据超过 8 字节限制/
232
+ );
233
+ }
234
+ );
235
+ });
236
+
237
+ test('dimension-check helpers expose stable structured failure bodies', () => {
238
+ const sanitized = sanitizeImageResponse({ images: [{ id: 'secret', b64_json: pngBase64(1, 1) }] });
239
+ assert.equal(sanitized.images[0].b64_json, undefined);
240
+ assert.equal(Object.prototype.hasOwnProperty.call(sanitized.images[0], 'b64_json'), false);
241
+ assert.equal(sanitized.images[0].b64_json_length, pngBase64(1, 1).length);
242
+ assert.deepEqual(parseExpectedDimensions('1024x768'), { width: 1024, height: 768 });
243
+ assert.equal(parseExpectedDimensions('auto'), undefined);
244
+ assert.equal(isDimensionCheckError(new Error('plain')), false);
245
+
246
+ const error = new DimensionCheckError('bad size', {
247
+ expected: { width: 10, height: 10 },
248
+ actual: { width: 8, height: 8 },
249
+ response: { images: [{ id: 'bad', b64_json: pngBase64(8, 8) }] },
250
+ nextStep: 'inspect'
251
+ });
252
+ const body = buildDimensionCheckFailureBody(error, { transport: 'agent_json' });
253
+ assert.equal(body.billable, true);
254
+ assert.equal(body.error.code, 'dimension_check_failed');
255
+ assert.deepEqual(body.error.expected_dimensions, { width: 10, height: 10 });
256
+ assert.deepEqual(body.error.actual_dimensions, { width: 8, height: 8 });
257
+ assert.equal(body.validation_failure_kind, 'generated_artifact_failed_dimension_check');
258
+ assert.deepEqual(body.routing, { transport: 'agent_json' });
259
+ assert.equal(body.response.images[0].b64_json, undefined);
260
+ assert.equal(Object.prototype.hasOwnProperty.call(body.response.images[0], 'b64_json'), false);
261
+ assert.equal(body.next_step, 'inspect');
262
+
263
+ const malformed = buildDimensionCheckFailureBody(
264
+ new DimensionCheckError('bad metadata', {
265
+ expected: { width: 'wide', height: 10 },
266
+ actual: { width: 8, height: 0 }
267
+ }),
268
+ undefined
269
+ );
270
+ assert.equal(malformed.error.expected_dimensions, null);
271
+ assert.equal(malformed.error.actual_dimensions, null);
272
+ });
273
+
274
+ function pngBase64(width, height) {
275
+ return pngBuffer(width, height).toString('base64');
276
+ }
277
+
278
+ function pngBuffer(width, height) {
279
+ const buffer = Buffer.alloc(24);
280
+ buffer[0] = 0x89;
281
+ buffer.write('PNG', 1, 'ascii');
282
+ buffer[4] = 0x0d;
283
+ buffer[5] = 0x0a;
284
+ buffer[6] = 0x1a;
285
+ buffer[7] = 0x0a;
286
+ buffer.writeUInt32BE(13, 8);
287
+ buffer.write('IHDR', 12, 'ascii');
288
+ buffer.writeUInt32BE(width, 16);
289
+ buffer.writeUInt32BE(height, 20);
290
+ return buffer;
291
+ }
292
+
293
+ async function withServer(handler, run) {
294
+ const server = createServer(handler);
295
+ await new Promise((resolve, reject) => {
296
+ server.once('error', reject);
297
+ server.listen(0, '127.0.0.1', resolve);
298
+ });
299
+ const address = server.address();
300
+ try {
301
+ await run(`http://127.0.0.1:${address.port}`);
302
+ } finally {
303
+ await new Promise((resolve, reject) => {
304
+ server.close((error) => (error ? reject(error) : resolve()));
305
+ });
306
+ }
307
+ }
scripts/image-dimensions.test.mjs ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readImageDimensions } from '../skills/gpt-image-playground-agent/scripts/lib/image-dimensions.mjs';
2
+ import assert from 'node:assert/strict';
3
+ import { test } from 'node:test';
4
+
5
+ test('readImageDimensions reads PNG dimensions', () => {
6
+ assert.deepEqual(readImageDimensions(pngBuffer(3840, 2160)), { width: 3840, height: 2160 });
7
+ });
8
+
9
+ test('readImageDimensions reads JPEG dimensions', () => {
10
+ assert.deepEqual(readImageDimensions(jpegBuffer(1280, 720)), { width: 1280, height: 720 });
11
+ });
12
+
13
+ test('readImageDimensions reads progressive JPEG dimensions', () => {
14
+ assert.deepEqual(readImageDimensions(jpegBuffer(1600, 900, 0xc2)), { width: 1600, height: 900 });
15
+ });
16
+
17
+ test('readImageDimensions reads WebP VP8X dimensions', () => {
18
+ assert.deepEqual(readImageDimensions(webpVp8xBuffer(1024, 1024)), { width: 1024, height: 1024 });
19
+ });
20
+
21
+ test('readImageDimensions reads WebP VP8L dimensions', () => {
22
+ assert.deepEqual(readImageDimensions(webpVp8lBuffer(512, 768)), { width: 512, height: 768 });
23
+ });
24
+
25
+ test('readImageDimensions reads WebP VP8 dimensions', () => {
26
+ assert.deepEqual(readImageDimensions(webpVp8Buffer(640, 360)), { width: 640, height: 360 });
27
+ });
28
+
29
+ test('readImageDimensions rejects empty buffers', () => {
30
+ assert.throws(() => readImageDimensions(Buffer.alloc(0)), /图片数据为空/);
31
+ });
32
+
33
+ test('readImageDimensions rejects unknown formats', () => {
34
+ assert.throws(() => readImageDimensions(Buffer.from('not-an-image')), /无法识别图片格式/);
35
+ });
36
+
37
+ test('readImageDimensions rejects truncated PNG buffers', () => {
38
+ assert.throws(() => readImageDimensions(pngBuffer(1, 1).subarray(0, 16)), /PNG 图片数据截断/);
39
+ });
40
+
41
+ test('readImageDimensions rejects truncated JPEG buffers', () => {
42
+ assert.throws(() => readImageDimensions(Buffer.from([0xff, 0xd8, 0xff, 0xc0, 0x00])), /JPEG 图片数据截断/);
43
+ });
44
+
45
+ test('readImageDimensions rejects JPEG SOF segments without enough metadata for dimensions', () => {
46
+ assert.throws(() => readImageDimensions(jpegBuffer(1280, 720, 0xc0, 0x07)), /JPEG 图片数据截断/);
47
+ });
48
+
49
+ test('readImageDimensions rejects truncated WebP buffers', () => {
50
+ assert.throws(() => readImageDimensions(webpHeader('VP8X', 20)), /WebP 图片数据截断/);
51
+ });
52
+
53
+ function pngBuffer(width, height) {
54
+ const buffer = Buffer.alloc(24);
55
+ buffer[0] = 0x89;
56
+ buffer.write('PNG', 1, 'ascii');
57
+ buffer[4] = 0x0d;
58
+ buffer[5] = 0x0a;
59
+ buffer[6] = 0x1a;
60
+ buffer[7] = 0x0a;
61
+ buffer.writeUInt32BE(13, 8);
62
+ buffer.write('IHDR', 12, 'ascii');
63
+ buffer.writeUInt32BE(width, 16);
64
+ buffer.writeUInt32BE(height, 20);
65
+ return buffer;
66
+ }
67
+
68
+ function jpegBuffer(width, height, marker = 0xc0, length = 0x0b) {
69
+ return Buffer.from([
70
+ 0xff,
71
+ 0xd8,
72
+ 0xff,
73
+ 0xe0,
74
+ 0x00,
75
+ 0x04,
76
+ 0x00,
77
+ 0x00,
78
+ 0xff,
79
+ marker,
80
+ 0x00,
81
+ length,
82
+ 0x08,
83
+ (height >> 8) & 0xff,
84
+ height & 0xff,
85
+ (width >> 8) & 0xff,
86
+ width & 0xff,
87
+ 0x01,
88
+ 0x01,
89
+ 0x11,
90
+ 0x00
91
+ ]);
92
+ }
93
+
94
+ function webpHeader(chunk, length) {
95
+ const buffer = Buffer.alloc(length);
96
+ buffer.write('RIFF', 0, 'ascii');
97
+ buffer.write('WEBP', 8, 'ascii');
98
+ buffer.write(chunk, 12, 'ascii');
99
+ return buffer;
100
+ }
101
+
102
+ function webpVp8xBuffer(width, height) {
103
+ const buffer = webpHeader('VP8X', 30);
104
+ buffer.writeUIntLE(width - 1, 24, 3);
105
+ buffer.writeUIntLE(height - 1, 27, 3);
106
+ return buffer;
107
+ }
108
+
109
+ function webpVp8lBuffer(width, height) {
110
+ const buffer = webpHeader('VP8L', 25);
111
+ const bits = ((height - 1) << 14) | (width - 1);
112
+ buffer.writeUInt32LE(bits, 21);
113
+ return buffer;
114
+ }
115
+
116
+ function webpVp8Buffer(width, height) {
117
+ const buffer = webpHeader('VP8 ', 30);
118
+ buffer.writeUInt16LE(width, 26);
119
+ buffer.writeUInt16LE(height, 28);
120
+ return buffer;
121
+ }
scripts/smoke-image-upstream-local-final-gate.test.mjs CHANGED
@@ -6,65 +6,69 @@ import { fileURLToPath } from 'node:url';
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', () => {
13
- const result = spawnSync(process.execPath, [scriptPath, '--timeout-ms', LOCAL_GATE_CASE_TIMEOUT_MS], {
14
- cwd: repoRoot,
15
- encoding: 'utf8'
16
- });
 
 
 
 
 
 
 
17
 
18
- assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
19
- assert.equal(result.stderr.trim(), '');
20
- assert.doesNotMatch(result.stdout, /local-fixture-key/);
21
- const report = JSON.parse(result.stdout);
22
- assert.equal(report.ok, true);
23
- assert.equal(report.local_fixture, true);
24
- assert.equal(report.final_gate_satisfied, true);
25
- assert.equal(report.independent_targets.configuration_complete, true);
26
- assert.deepEqual(report.request_modes.passed, [
27
- 'images-non-stream',
28
- 'images-sse',
29
- 'responses-non-stream',
30
- 'responses-sse'
31
- ]);
32
- assert.equal(
33
- report.suggested_channel_config,
34
- 'images-non-stream,images-sse,responses-non-stream,responses-sse'
35
- );
36
- assert.equal(report.results.length, 6);
37
- assert.deepEqual(
38
- report.results.map((item) => item.id),
39
- [
40
- 'original-images-json',
41
- 'gaoren-images-sse',
42
- 'sub2api-images-sse',
43
- 'sub2api-responses-json',
44
- 'gpt2image-responses-sse',
45
- 'matsca-images-sse'
46
- ]
47
- );
48
- assert.deepEqual(
49
- report.results.map((item) => item.request_mode),
50
- [
51
  'images-non-stream',
52
  'images-sse',
53
- 'images-sse',
54
  'responses-non-stream',
55
- 'responses-sse',
56
- 'images-sse'
57
- ]
58
- );
59
- assert.equal(
60
- report.results.every((item) => item.status === 200),
61
- true
62
- );
63
- assert.equal(
64
- report.results.every((item) => item.first_b64_length === 92),
65
- true
66
- );
67
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
  it('prints help without starting the fixture', () => {
70
  const result = spawnSync(process.execPath, [scriptPath, '--help'], {
 
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 DEFAULT_LOCAL_GATE_CASE_TIMEOUT_MS = '120000';
10
+ const LOCAL_GATE_CASE_TIMEOUT_MS =
11
+ process.env.LOCAL_IMAGE_UPSTREAM_GATE_TIMEOUT_MS || DEFAULT_LOCAL_GATE_CASE_TIMEOUT_MS;
12
+ const RUN_LOCAL_FINAL_GATE = process.env.GPT_IMAGE_RUN_LOCAL_FINAL_GATE === '1';
13
 
14
  describe('local image upstream final gate smoke launcher', () => {
15
+ it(
16
+ 'runs all independent upstream cases against the local fixture',
17
+ {
18
+ skip: RUN_LOCAL_FINAL_GATE
19
+ ? false
20
+ : 'set GPT_IMAGE_RUN_LOCAL_FINAL_GATE=1 to run the local fixture final gate'
21
+ },
22
+ () => {
23
+ const result = spawnSync(process.execPath, [scriptPath, '--timeout-ms', LOCAL_GATE_CASE_TIMEOUT_MS], {
24
+ cwd: repoRoot,
25
+ encoding: 'utf8'
26
+ });
27
 
28
+ assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
29
+ assert.equal(result.stderr.trim(), '');
30
+ assert.doesNotMatch(result.stdout, /local-fixture-key/);
31
+ const report = JSON.parse(result.stdout);
32
+ assert.equal(report.ok, true);
33
+ assert.equal(report.local_fixture, true);
34
+ assert.equal(report.final_gate_satisfied, true);
35
+ assert.equal(report.independent_targets.configuration_complete, true);
36
+ assert.deepEqual(report.request_modes.passed, [
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  'images-non-stream',
38
  'images-sse',
 
39
  'responses-non-stream',
40
+ 'responses-sse'
41
+ ]);
42
+ assert.equal(
43
+ report.suggested_channel_config,
44
+ 'images-non-stream,images-sse,responses-non-stream,responses-sse'
45
+ );
46
+ assert.equal(report.results.length, 6);
47
+ assert.deepEqual(
48
+ report.results.map((item) => item.id),
49
+ [
50
+ 'original-images-json',
51
+ 'gaoren-images-sse',
52
+ 'sub2api-images-sse',
53
+ 'sub2api-responses-json',
54
+ 'gpt2image-responses-sse',
55
+ 'matsca-images-sse'
56
+ ]
57
+ );
58
+ assert.deepEqual(
59
+ report.results.map((item) => item.request_mode),
60
+ ['images-non-stream', 'images-sse', 'images-sse', 'responses-non-stream', 'responses-sse', 'images-sse']
61
+ );
62
+ assert.equal(
63
+ report.results.every((item) => item.status === 200),
64
+ true
65
+ );
66
+ assert.equal(
67
+ report.results.every((item) => item.first_b64_length === 92),
68
+ true
69
+ );
70
+ }
71
+ );
72
 
73
  it('prints help without starting the fixture', () => {
74
  const result = spawnSync(process.execPath, [scriptPath, '--help'], {
scripts/smoke-image-upstream-real.mjs CHANGED
@@ -1,9 +1,8 @@
1
  #!/usr/bin/env node
 
2
  import crypto from 'node:crypto';
3
  import fs from 'node:fs';
4
 
5
- import { CHANNEL_REQUEST_MODES } from '../src/lib/channel-request-mode-values.mjs';
6
-
7
  const originalEnv = { ...process.env };
8
  const CASES = [
9
  {
@@ -231,8 +230,12 @@ function buildRequestModeSummary(results) {
231
  const modes = Object.fromEntries(
232
  CHANNEL_REQUEST_MODES.map((mode) => {
233
  const cases = results.filter((item) => item.request_mode === mode);
234
- const passedCases = cases.filter((item) => item.ok === true && item.skipped !== true && item.blocked !== true);
235
- const failedCases = cases.filter((item) => item.ok !== true && item.skipped !== true && item.blocked !== true);
 
 
 
 
236
  const skippedCases = cases.filter((item) => item.skipped === true);
237
  return [
238
  mode,
@@ -432,7 +435,14 @@ async function runCase(loadRouteHandlersForBillable, testCase, preflight = {}) {
432
  const startedAt = Date.now();
433
  const abortController = new AbortController();
434
  return withCaseTimeout(
435
- () => runBillableCaseAfterLoadingHandlers(loadRouteHandlersForBillable, testCase, target, startedAt, abortController.signal),
 
 
 
 
 
 
 
436
  testCase,
437
  target,
438
  startedAt,
@@ -480,6 +490,7 @@ function withCaseTimeout(run, testCase, target, startedAt, abortController) {
480
  const timeout = setTimeout(() => {
481
  settled = true;
482
  abortController.abort();
 
483
  resolve({
484
  id: testCase.id,
485
  request_mode: testCase.requestMode,
 
1
  #!/usr/bin/env node
2
+ import { CHANNEL_REQUEST_MODES } from '../src/lib/channel-request-mode-values.mjs';
3
  import crypto from 'node:crypto';
4
  import fs from 'node:fs';
5
 
 
 
6
  const originalEnv = { ...process.env };
7
  const CASES = [
8
  {
 
230
  const modes = Object.fromEntries(
231
  CHANNEL_REQUEST_MODES.map((mode) => {
232
  const cases = results.filter((item) => item.request_mode === mode);
233
+ const passedCases = cases.filter(
234
+ (item) => item.ok === true && item.skipped !== true && item.blocked !== true
235
+ );
236
+ const failedCases = cases.filter(
237
+ (item) => item.ok !== true && item.skipped !== true && item.blocked !== true
238
+ );
239
  const skippedCases = cases.filter((item) => item.skipped === true);
240
  return [
241
  mode,
 
435
  const startedAt = Date.now();
436
  const abortController = new AbortController();
437
  return withCaseTimeout(
438
+ () =>
439
+ runBillableCaseAfterLoadingHandlers(
440
+ loadRouteHandlersForBillable,
441
+ testCase,
442
+ target,
443
+ startedAt,
444
+ abortController.signal
445
+ ),
446
  testCase,
447
  target,
448
  startedAt,
 
490
  const timeout = setTimeout(() => {
491
  settled = true;
492
  abortController.abort();
493
+ clearTimeout(timeout);
494
  resolve({
495
  id: testCase.id,
496
  request_mode: testCase.requestMode,
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -37,12 +37,12 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
37
 
38
  执行决策表:
39
 
40
- | 场景 | Agent 输入 | 服务端职责 | 结果字段 |
41
- | --- | --- | --- | --- |
42
- | 普通文生图 | prompt、尺寸、质量等业务意图 | 通过 `orchestration.endpoint` 选择 Agent/job、渠道、Images/Responses、SSE/非流式 | `summary.transport`、`summary.route_mode`、`summary.channel_request_mode`、`summary.route_decision` |
43
- | 自动上游流式 | `stream_mode=auto` 或默认值 | 若 SSE 渠道不可用,可在服务端显式退到非流式并标记 fallback | `summary.channel_request_mode_fallback_applied=true` |
44
- | 显式流式诊断 | `stream_mode=stream` 或显式 `--page-sse` | 失败必须显式返回错误,不静默改成非流式 | `summary.route_decision.no_channel_reason` 或结构化错误 |
45
- | 管理员渠道白名单 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 只约束服务端可选渠道,不授权 Agent 客户端自选 endpoint | `capabilities.request_mode_controls`、`agent:doctor.summary.request_modes` |
46
 
47
  - 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。
48
  - `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
@@ -119,14 +119,14 @@ Authorization: Bearer <token>
119
 
120
  以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
121
 
122
- - `scripts/generate-image.mjs`:文生图调用。默认 dry-run,不消耗额度;真实执行默认提交到服务端编排入口,必须添加 `--allow-billable` 才会真实生图。需要浏览可直接打开的用户外链时添加 `--share`,可选 `--share-expires-minutes`;私密分享访问码从 `GPT_IMAGE_SHARE_ACCESS_CODE` 读取,不放进命令行参数。
123
- - `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
124
  - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持 append-only manifest、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。
125
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
126
  - `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
127
  - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
128
 
129
- 生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成 dry-run 添加 `--check-remote` 后会只读查询 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,输出 `verification_scope.mode=remote_contract_and_local_planning`,仍不会发送真实生图请求。生成 dry-run 默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`,便于人工审查。
130
  所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
131
 
132
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
@@ -141,27 +141,27 @@ Authorization: Bearer <token>
141
 
142
  首次配置和诊断输出字段速查:
143
 
144
- | 字段 | 出现位置 | 判断口径 |
145
- | --- | --- | --- |
146
- | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
147
- | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
148
- | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
149
- | `agent_auth_process.has_token` | `first-run --json` | 当前进程是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
150
- | `page_sse_auth_available_to_process` | `first-run --json` | 目标服务要求页面 SSE `passwordHash` 时,当前进程是否已加载 `GPT_IMAGE_APP_PASSWORD_HASH`。 |
151
- | `summary.page_sse_auth_ready` | `agent:doctor` | 页面 SSE 鉴权是否已满足;为 `false` 时不要运行 `--page-sse` 真实计费请求。 |
152
- | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
153
- | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
154
- | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
155
- | `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 |
156
- | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
157
- | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
158
- | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
159
- | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `orchestration_generate_1k`、`agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
160
- | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
161
- | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate ��� `agent_client_policy=diagnostics_only`。 |
162
- | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
163
- | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
164
- | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
165
 
166
  生成脚本常用参数:
167
 
@@ -227,7 +227,7 @@ node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.sp
227
  node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.space --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3
228
  ```
229
 
230
- `--manifest` 使用 JSONL append-only 记录每条任务的 `index`、`id`、`idempotency_key`、`attempt`、`status`、响应或错误以及机器可读 `summary`;Agent JSON 失败时 manifest 也会记录只读诊断补采样得到的 `agent_failure_diagnostics`。`--resume` 会读取已成功记录并跳过同一 `id` 或 `idempotency_key`。dry-run 不写 manifest,输出会声明 `manifest_written=false`、`manifest_write_reason=dry_run` 和 `guardrails`;真实执行应复用 dry-run 中的同一个 `--ordered-prefix`,否则未显式写 `idempotency_key` 的任务会生成不同 key。`--dimension-check` 会读取响应里的 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸是否等于任务 `size`;通过时成功摘要写入 `image_dimensions` 和单图 `actual_dimensions`,失败时 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`summary.expected_dimensions`、`summary.actual_dimensions`、`summary.dimension_check_failed=true` 会同时写入 manifest。固定尺寸任务没有开启时,dry-run 会在 `guardrails.dimension_check_recommended` 中提示。`--max-attempts` 会为第二次及以后尝试追加新的 attempt 级 idempotency key,避免复用终态失败 key;`--concurrency` 大于 `1` 时会先读取运行态并发建议,并发执行任务并按输入顺序输出结果。服务端 `recommendedConcurrency` 或 `channelQueue.capacityPerCredential` 小于请求值时,脚本会把有效并发降到建议值并在输出中写入 `capacity_feedback`;不要再另开多个单张脚本绕过这个限制。`failure_summary` 会区分 `validation_failure_count` 和 `request_failure_count`,避免把已生成但验收失败误判为上游调用失败。`--max-consecutive-failures` 会在连续失败达到阈值后跳过后续任务并输出 `failure_summary` 与 `resume_fix_list`,且只能与顺序执行的 `--concurrency 1` 同用。任务级 `sse_log_path` 会把页面 SSE 原始事件按 JSONL 追加保存;即使 fetch 或 SSE 收集阶段失败,也会记录 `request_started`、`request_failed`、`elapsed_ms`、`client_request_id` 和 `endpoint`,便于区分上游未给终图和解析/断流问题。
231
 
232
  批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。批量 generate 默认提交到 `/api/agent/image-requests`,`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 会作为 generate 意图字段随 JSON 发送给服务端编排入口。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images` 和上述 Responses 控制字段仍可用于页面 SSE edit 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚本在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
233
 
 
37
 
38
  执行决策表:
39
 
40
+ | 场景 | Agent 输入 | 服务端职责 | 结果字段 |
41
+ | ---------------- | ----------------------------------------------------------------- | -------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
42
+ | 普通文生图 | prompt、尺寸、质量等业务意图 | 通过 `orchestration.endpoint` 选择 Agent/job、渠道、Images/Responses、SSE/非流式 | `summary.transport`、`summary.route_mode`、`summary.channel_request_mode`、`summary.route_decision` |
43
+ | 自动上游流式 | `stream_mode=auto` 或默认值 | 若 SSE 渠道不可用,可在服务端显式退到非流式并标记 fallback | `summary.channel_request_mode_fallback_applied=true` |
44
+ | 显式流式诊断 | `stream_mode=stream` 或显式 `--page-sse` | 失败必须显式返回错误,不静默改成非流式 | `summary.route_decision.no_channel_reason` 或结构化错误 |
45
+ | 管理员渠道白名单 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 只约束服务端可选渠道,不授权 Agent 客户端自选 endpoint | `capabilities.request_mode_controls`、`agent:doctor.summary.request_modes` |
46
 
47
  - 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。
48
  - `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
 
119
 
120
  以下脚本都位于当前 Skill 目录的 `scripts/` 下。不要硬编码本机安装路径;由运行环境按当前 `SKILL.md` 所在目录解析脚本路径。
121
 
122
+ - `scripts/generate-image.mjs`:文生图调用。默认 dry-run,不消耗额度;真实执行默认提交到服务端编排入口,必须添加 `--allow-billable` 才会真实生图。固定尺寸任务可添加 `--dimension-check`,从内联图片或同源 artifact URL 读取 PNG/JPEG/WebP 尺寸并把上游尺寸偏差判为结构化验收失败。需要浏览���可直接打开的用户外链时添加 `--share`,可选 `--share-expires-minutes`;私密分享访问码从 `GPT_IMAGE_SHARE_ACCESS_CODE` 读取,不放进命令行参数。
123
+ - `scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。固定尺寸任务可添加 `--dimension-check`,脚本会从内联图片或同源 artifact URL 读取 PNG/JPEG/WebP 尺寸,并把上游尺寸偏差判为结构化验收失败。
124
  - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实执行,支持 append-only manifest、`--resume`、`--ordered-prefix`、`--dimension-check`、`--max-attempts`、`--concurrency` 和顺序执行下的 `--max-consecutive-failures`。`--concurrency` 默认 `1`,大于 `1` 时并发执行并按输入顺序输出结果。
125
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。默认输出 WebP,质量 `100`;JPEG 会把透明背景铺成白色,PNG/WebP 保留透明。
126
  - `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
127
  - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
128
 
129
+ 生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成 dry-run 添加 `--check-remote` 后会只读查询 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,输出 `verification_scope.mode=remote_contract_and_local_planning`,仍不会发送真实生图请求。生成 dry-run 默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。单张生成、单张编辑和批量任务都支持 `--dimension-check`;批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport`、`endpoint`、`route_mode`、`channel_request_mode`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;失败摘要的渠道与路由字段优先读取 `error.diagnostics`,没有对应诊断字段时才回退到响应里的 `execution`。尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions`服务端 `execution` 选路字段,便于人工审查。
130
  所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
131
 
132
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
 
141
 
142
  首次配置和诊断输出字段速查:
143
 
144
+ | 字段 | 出现位置 | 判断口径 |
145
+ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
146
+ | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
147
+ | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
148
+ | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
149
+ | `agent_auth_process.has_token` | `first-run --json` | 当前进程是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
150
+ | `page_sse_auth_available_to_process` | `first-run --json` | 目标服务要求页面 SSE `passwordHash` 时,当前进程是否已加载 `GPT_IMAGE_APP_PASSWORD_HASH`。 |
151
+ | `summary.page_sse_auth_ready` | `agent:doctor` | 页面 SSE 鉴权是否已满足;为 `false` 时不要运行 `--page-sse` 真实计费请求。 |
152
+ | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
153
+ | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
154
+ | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
155
+ | `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 |
156
+ | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
157
+ | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
158
+ | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
159
+ | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `orchestration_generate_1k`、`agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
160
+ | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要,包含 `supported`、`configured`、`effective`、`admin_whitelist_by_channel`、`effective_by_channel`、带 `severity` 的 `gaps`、`suggested_channel_env_key`、`suggested_effective_value` 和 `next_action`;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
161
+ | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate `agent_client_policy=diagnostics_only`。 |
162
+ | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
163
+ | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
164
+ | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
165
 
166
  生成脚本常用参数:
167
 
 
227
  node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.space --allow-billable --input tasks.jsonl --manifest runs/product-set.manifest.jsonl --resume --dimension-check --max-attempts 2 --concurrency 3
228
  ```
229
 
230
+ `--manifest` 使用 JSONL append-only 记录每条任务的 `index`、`id`、`idempotency_key`、`attempt`、`status`、响应或错误以及机器可读 `summary`;Agent JSON 失败时 manifest 也会记录只读诊断补采样得到的 `agent_failure_diagnostics`。`--resume` 会读取已成功记录并跳过同一 `id` 或 `idempotency_key`。dry-run 不写 manifest,输出会声明 `manifest_written=false`、`manifest_write_reason=dry_run` 和 `guardrails`;真实执行应复用 dry-run 中的同一个 `--ordered-prefix`,否则未显式写 `idempotency_key` 的任务会生成不同 key。`--dimension-check` 会读取响应里的 `b64_json` 或同 origin `absolute_content_url`/`content_url`/`absolute_path`/`path`,校验 PNG/JPEG/WebP 尺寸是否等于任务 `size`;通过时成功摘要写入 `image_dimensions` 和单图 `actual_dimensions`,失败时 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`summary.expected_dimensions`、`summary.actual_dimensions`、`summary.dimension_check_failed=true` 会同时写入 manifest。固定尺寸任务没有开启时,dry-run 会在 `guardrails.dimension_check_recommended` 中提示。`--max-attempts` 会为第二次及以后尝试追加新的 attempt 级 idempotency key,避免复用终态失败 key;`--concurrency` 大于 `1` 时会先读取运行态并发建议,并发执行任务并按输入顺序输出结果。服务端 `recommendedConcurrency` 或 `channelQueue.capacityPerCredential` 小于请求值时,脚本会把有效并发降到建议值并在输出中写入 `capacity_feedback`;不要再另开多个单张脚本绕过这个限制。`failure_summary` 会区分 `validation_failure_count` 和 `request_failure_count`,避免把已生成但验收失败误判为上游调用失败。`--max-consecutive-failures` 会在连续失败达到阈值后跳过后续任务并输出 `failure_summary` 与 `resume_fix_list`,且只能与顺序执行的 `--concurrency 1` 同用。任务级 `sse_log_path` 会把页面 SSE 原始事件按 JSONL 追加保存;即使 fetch 或 SSE 收集阶段失败,也会记录 `request_started`、`request_failed`、`elapsed_ms`、`client_request_id` 和 `endpoint`,便于区分上游未给终图和解析/断流问题。
231
 
232
  批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。批量 generate 默认提交到 `/api/agent/image-requests`,`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 会作为 generate 意图字段随 JSON 发送给服务端编排入口。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images` 和上述 Responses 控制字段仍可用于页面 SSE edit 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`。JSONL 字段名必须使用 `streaming_strategy`;`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段,会被脚本在真实请求前拒绝。PNG 搭配 `output_compression` 会在 dry-run 标记 normalization,真实请求不会发送压缩字段。`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`。脚本会在 dry-run 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
233
 
skills/gpt-image-playground-agent/references/api.md CHANGED
@@ -23,7 +23,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
23
  生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用。
24
 
25
  - `scripts/generate-image.mjs`:JSON 文生图调用。
26
- - `scripts/edit-image.mjs`:multipart 编辑调用。
27
  - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
28
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。
29
  - `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
@@ -43,11 +43,12 @@ Hugging Face Space Secrets 只能写入和列出名称,不能从 CLI 读回 se
43
  npm run env:summary
44
  npm run env:summary -- --file .env.local --container gpt-image-playground-customer
45
  ```
 
46
  当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。
47
  同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
48
  页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
49
  脚本会在 dry-run 和真实请求前前置校验 `--size` 或 JSONL `size`。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量和上游探针默认请求 `output_format=webp`、`output_compression=100`。
50
- 真实执行输出会包含机器可读 `summary`。成功摘要包含 `ok`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。`transport` 表示 Agent 对外访问的服务端端点形态,`route_mode` 表示 Agent/job/page SSE 路径,`channel_request_mode` 表示服务端实际调用上游的 Images/Responses 与 SSE/非流式组合,`route_decision` 记录 requested backend、preferred/fallback/selected request mode、fallback 是否发生、选中渠道、上游 host 或 no-channel 原因。`share_urls` 只在显式 `--share` 后出现,用于给用户浏览器打开分享页;`direct_content_urls` 只在显式 `--share` 后出现,用于分享后的内容直链;公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`;`content_urls` 仍是需要 Agent 鉴权的 artifact 下载路径。失败摘要也稳定包含空数组或 `null` 形式的产物、路由、渠道和尺寸字段,便于 subagent 按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`。Agent JSON 失败时脚本会按幂等键只读查询 Agent state;若命中,会把 `request_id`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。
51
 
52
  生成脚本参数:
53
 
@@ -68,6 +69,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
68
  - `--partial-images`:可选,显式设置上游 SSE partial image 数量。generate 或页面 SSE 请求包含 `image_backend` 时优先按 capabilities 的 `limits.partial_images_by_backend[image_backend]` 校验;缺少 backend 专属范围时才使用 `limits.partial_images`。
69
  - `--share`:真实生图成功后,为每个 Agent artifact 调用 `POST /api/agent/artifacts/{id}/share` 创建用户可打开的分享链接,并在顶层 `shares`、`summary.share_urls` 和 `summary.direct_content_urls` 输出结果。
70
  - `--share-expires-minutes`:可选,设置分享有效期分钟数;省略时使用服务端默认值。
 
71
  - `GPT_IMAGE_SHARE_ACCESS_CODE`:可选,创建需要访问码的分享链接;访问码不会出现在返回 URL 中,也不会出现在命令行参数里。
72
  - `--timeout-ms`:未显式指定时,脚本先用 `420000ms` 读取 capabilities;真实请求会采用 `420000ms` 与 `capabilities.image_transport.upstream_timeout_ms` 中较大的值。
73
  - `--prompt-file`:从文本文件读取 prompt。
@@ -103,6 +105,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
103
  - `--sse-log`
104
  - `--timeout-ms`
105
  - `--idempotency-key`
 
106
  - `--page-sse`
107
  - `--agent`
108
  - `--dry-run`
@@ -110,7 +113,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
110
 
111
  图片路径可以用位置参数 `<image-path> <prompt>`,也可以用 `--image <path> <prompt>`;两者不能同时设置。
112
  默认 WebP edit 走页面端 `/api/images` form-data SSE,因为 Agent edit 不接收输出格式字段。显式 `--format`、`--output-compression`、`--image-backend responses-image-generation`、页面高级字段或 `--page-sse` 也会走页面 SSE;失败后脚本输出结构化失败和备用端点建议,不会在同一次请求里静默二次调用。
113
- 显式 `--page-sse` 会强制页面流式;显式 `--agent` 会走 Agent edit 最终 JSON。Agent edit 不接受 `image_backend`、`output_format` 或 `output_compression`;强制 Agent edit 时输出格式固定为 PNG,`partial_images` 按默认 Images API/profile 范围校验。Agent edit 只是页面 SSE 失败后的显式对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须使用批量 `--dimension-check` 或下载后校验。Responses image_generation edit 必须走页面 SSE,可显式设置 `--page-sse --image-backend responses-image-generation --streaming-strategy responses-sse`。如果运行时已显式配置 `IMAGE_GENERATION_BACKEND=responses-image-generation` 或兼容别名 `responses`,且 `IMAGE_STREAMING_STRATEGY=responses-sse`,也可依赖服务端默认值;Docker compose 本身不设置这两个默认值,未配置 `.env.local` 时仍是 `images-api` 和 `auto`。默认 WebP edit 与 `stream_mode=non_stream` / `streaming_strategy=off` 冲突时脚本会前置拒绝;需要 Agent JSON 对照时必须显式添加 `--agent`,并使用新的 `Idempotency-Key`。
114
 
115
  批量脚本参数:
116
 
@@ -118,7 +121,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
118
  - `--manifest`:append-only JSONL manifest 路径,默认 `<input>.manifest.jsonl`。
119
  - `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。
120
  - `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
121
- - `--dimension-check`:读取响应 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸等于任务 `size`;通过时 summary 写入实际尺寸,失败时写入 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`expected_dimensions` 和 `actual_dimensions`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
122
  - `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
123
  - `--concurrency`:并发执行窗口,默认 `1`。大于 `1` 时会先读取 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`,把有效并发限制到服务端建议值后按输入顺序输出结果;适合已确认渠道容量的批量生产。
124
  - `--max-consecutive-failures`:顺序执行下的连续失败熔断阈值,默认 `0` 表示不熔断。只能与 `--concurrency 1` 同用。
@@ -133,7 +136,15 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
133
  Responses edit JSONL 正例:
134
 
135
  ```jsonl
136
- {"id":"edit-responses","mode":"edit","prompt":"replace the background","image_path":"source.png","image_backend":"responses-image-generation","streaming_strategy":"responses-sse","partial_images":1}
 
 
 
 
 
 
 
 
137
  ```
138
 
139
  dry-run 预期:`routing.transport=page_sse`、`endpoint=/api/images`、`request.image_backend=responses-image-generation`。
@@ -141,7 +152,13 @@ dry-run 预期:`routing.transport=page_sse`、`endpoint=/api/images`、`reques
141
  缺少 `image_backend` 的反例:
142
 
143
  ```jsonl
144
- {"id":"edit-responses-missing-backend","mode":"edit","prompt":"replace the background","image_path":"source.png","responsesModel":"gpt-4.1"}
 
 
 
 
 
 
145
  ```
146
 
147
  dry-run 预期退出码为 `2`,错误包含 `responsesModel 必须同时设置 image_backend=responses-image-generation`。
@@ -229,6 +246,7 @@ GET /api/agent/capabilities
229
  - `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
230
 
231
  新增 probe、diagnostics 或健康摘要时,先把机器契约放进 capabilities、OpenAPI 或明确的 Agent 只读端点,再让脚本消费这些字段;不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑。
 
232
  - `defaults.image_backend`:Agent generate 默认 `images-api`。
233
  - `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。
234
  - `defaults.streaming_strategy`:Agent generate 默认 `auto`。
@@ -265,18 +283,18 @@ Content-Type: application/json
265
 
266
  ```json
267
  {
268
- "job": {
269
- "id": "job-request-uuid",
270
- "request_id": "job-request-uuid",
271
- "idempotency_key": "stable-key",
272
- "mode": "generate",
273
- "state": "running",
274
- "created_at": "2026-05-20T00:00:00.000Z",
275
- "updated_at": "2026-05-20T00:00:00.000Z",
276
- "expires_at": "2026-05-21T00:00:00.000Z",
277
- "result_url": "/api/agent/jobs/job-request-uuid/result",
278
- "retry_after_seconds": 5
279
- }
280
  }
281
  ```
282
 
@@ -322,20 +340,20 @@ Content-Type: application/json
322
 
323
  ```json
324
  {
325
- "prompt": "a product photo of a ceramic mug",
326
- "model": "gpt-image-2",
327
- "n": 1,
328
- "size": "1024x1024",
329
- "quality": "high",
330
- "output_format": "webp",
331
- "output_compression": 100,
332
- "background": "auto",
333
- "moderation": "auto",
334
- "response_mode": "path",
335
- "image_backend": "images-api",
336
- "stream_mode": "auto",
337
- "streaming_strategy": "auto",
338
- "partial_images": 2
339
  }
340
  ```
341
 
@@ -353,47 +371,47 @@ Agent JSON 生成端点对外始终返回最终 JSON,不会对客户端返回
353
 
354
  ```json
355
  {
356
- "request_id": "uuid",
357
- "idempotency_key": "stable-key",
358
- "cached": false,
359
- "images": [
360
- {
361
- "id": "artifact-uuid",
362
- "filename": "1715400000000-abcdef1234567890-0.webp",
363
- "content_url": "/api/agent/artifacts/artifact-uuid/content",
364
- "metadata_url": "/api/agent/artifacts/artifact-uuid",
365
- "output_format": "webp",
366
- "mime_type": "image/webp",
367
- "size_bytes": 12345,
368
- "width": 1024,
369
- "height": 1024
370
- }
371
- ],
372
- "usage": {},
373
- "created_at": "2026-05-12T00:00:00.000Z",
374
- "timing": {
375
- "started_at": "2026-05-12T00:00:00.000Z",
376
- "completed_at": "2026-05-12T00:01:04.000Z",
377
- "elapsed_ms": 64000,
378
- "server_elapsed_ms": 64000
379
- },
380
- "execution": {
381
- "transport": "agent_job_polling",
382
- "endpoint": "/api/agent/image-requests",
383
- "route_mode": "job",
384
- "operation": "generate",
385
- "image_backend": "images-api",
386
- "stream_mode": "non_stream",
387
- "streaming_strategy": "off",
388
- "selected_channel_id": "default",
389
- "upstream_host": "api.example.test",
390
- "request_headers": {
391
- "user_agent_effective": "gpt-image-playground/2.1.0",
392
- "has_extra_headers": false,
393
- "allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
394
- "configured_header_names": []
 
395
  }
396
- }
397
  }
398
  ```
399
 
@@ -473,15 +491,15 @@ Agent request diagnostics 来自 Agent state 后端,适用于 `/api/agent/imag
473
 
474
  ```json
475
  {
476
- "target": { "type": "page_request", "id": "stable-operation-key" },
477
- "feedback": {
478
- "target_type": "page_request",
479
- "target_id": "stable-operation-key",
480
- "value": "usable",
481
- "source": "webui",
482
- "updated_at": "2026-05-12T00:00:00.000Z",
483
- "note": "approved"
484
- }
485
  }
486
  ```
487
 
@@ -491,7 +509,7 @@ Agent request diagnostics 来自 Agent state 后端,适用于 `/api/agent/imag
491
 
492
  ```json
493
  {
494
- "ids": ["stable-operation-key", "stable-operation-key-2"]
495
  }
496
  ```
497
 
@@ -501,8 +519,8 @@ Agent request diagnostics 来自 Agent state 后端,适用于 `/api/agent/imag
501
 
502
  ```json
503
  {
504
- "ids": ["stable-operation-key", "stable-operation-key-2"],
505
- "filenames": ["output.png"]
506
  }
507
  ```
508
 
@@ -512,50 +530,42 @@ Agent request diagnostics 来自 Agent state 后端,适用于 `/api/agent/imag
512
 
513
  ```json
514
  {
515
- "scope": {
516
- "request_ids": ["stable-operation-key"],
517
- "filenames": ["output.png"],
518
- "filename_matched_request_ids": [],
519
- "copy_text": "requestIds=stable-operation-key filename=output.png"
520
- },
521
- "matched_log_count": 0,
522
- "events": [],
523
- "diagnostics_retention": {
524
- "storage": "bounded_local_jsonl",
525
- "max_entries": 300,
526
- "default_max_entries": 300,
527
- "min_entries": 100,
528
- "max_configured_entries": 5000,
529
- "configured_by": "APP_LOG_MAX_ENTRIES",
530
- "persisted_across_process_restart": true,
531
- "loss_modes": [
532
- "entry_evicted_by_max_entries",
533
- "log_level_filter",
534
- "local_log_file_missing_or_cleared"
535
- ],
536
- "bounded": true,
537
- "not_agent_state_backend": true
538
- },
539
- "diagnostics_note": {
540
- "code": "no_matching_logs_in_retention_window",
541
- "message": "没有匹配到页面请求日志;诊断只覆盖最近 300 条本地应用日志,日志可能已被保留条数淘汰、被日志级别过滤,或本地日志文件被清理。",
542
- "retention": {
543
- "storage": "bounded_local_jsonl",
544
- "max_entries": 300,
545
- "default_max_entries": 300,
546
- "min_entries": 100,
547
- "max_configured_entries": 5000,
548
- "configured_by": "APP_LOG_MAX_ENTRIES",
549
- "persisted_across_process_restart": true,
550
- "loss_modes": [
551
- "entry_evicted_by_max_entries",
552
- "log_level_filter",
553
- "local_log_file_missing_or_cleared"
554
- ],
555
- "bounded": true,
556
- "not_agent_state_backend": true
557
  }
558
- }
559
  }
560
  ```
561
 
@@ -581,71 +591,71 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
581
 
582
  首次配置和诊断输出字段速查:
583
 
584
- | 字段 | 出现位置 | 判断口径 |
585
- | --- | --- | --- |
586
- | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
587
- | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
588
- | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
589
- | `agent_auth_process.has_token` | `first-run --json` | 当前进程是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
590
- | `page_sse_auth_available_to_process` | `first-run --json` | 目标服务要求页面 SSE `passwordHash` 时,当前进程是否已加载 `GPT_IMAGE_APP_PASSWORD_HASH`。 |
591
- | `summary.page_sse_auth_ready` | `agent:doctor` | 页面 SSE 鉴权是否已满足;为 `false` 时不要运行 `--page-sse` 真实计费请求。 |
592
- | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
593
- | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
594
- | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
595
- | `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 |
596
- | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
597
- | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
598
- | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
599
- | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `orchestration_generate_1k`、`agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
600
- | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
601
- | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
602
- | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
603
- | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
604
- | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口无匹配日志不等于请求一定没发生。 |
605
 
606
  单条 Agent state 诊断响应示例:
607
 
608
  ```json
609
  {
610
- "found": true,
611
- "diagnostics": {
612
- "request": {
613
- "request_id": "req_abc",
614
- "idempotency_key": "stable-operation-key",
615
- "mode": "generate",
616
- "status": "succeeded",
617
- "cached": false,
618
- "created_at": "2026-05-12T00:00:00.000Z",
619
- "updated_at": "2026-05-12T00:01:04.000Z",
620
- "expires_at": "2026-05-13T00:00:00.000Z"
621
- },
622
- "response": {
623
- "image_count": 1,
624
- "artifact_ids": ["artifact-uuid"],
625
- "content_urls": ["/api/agent/artifacts/artifact-uuid/content"],
626
- "timing": {
627
- "elapsed_ms": 64000,
628
- "server_elapsed_ms": 64000
629
- },
630
- "execution": {
631
- "transport": "agent_json",
632
- "endpoint": "/api/agent/images/generate",
633
- "request_headers": {
634
- "user_agent_effective": "gpt-image-playground/2.1.0",
635
- "has_extra_headers": false,
636
- "allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
637
- "configured_header_names": []
 
 
 
 
 
 
 
 
 
638
  }
639
- }
640
- },
641
- "state_backend": "sqlite",
642
- "diagnostics_retention": {
643
- "storage": "agent_state",
644
- "ttl_seconds": 86400,
645
- "bounded": true,
646
- "loss_modes": ["request_expired_by_ttl", "artifact_deleted_or_purged", "state_backend_reset"]
647
  }
648
- }
649
  }
650
  ```
651
 
@@ -666,21 +676,21 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
666
 
667
  ### 边界矩阵
668
 
669
- | 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 |
670
- | --- | --- | --- | --- |
671
- | `POST /api/agent/image-requests`、`POST /api/agent/images/generate`、`POST /api/agent/images/edit`、Agent jobs、Agent artifacts、`POST /api/agent/artifacts/{id}/share` | Agent API | 是 | 普通 generate 默认用 image-requests;其他 Agent 端点通过 skill 脚本和 Agent 鉴权调用。分享创建需要 Agent 鉴权,返回的分享 URL 给用户浏览器访问。 |
672
- | `POST /api/images` | 页面 form-data SSE API | 否 | 仅在默认 WebP edit、复杂 UI 批量、页面高级字段或显式 `--page-sse` 诊断时由 skill 选择。 |
673
- | `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 |
674
- | `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 |
675
- | `POST /api/agent/page-requests/feedback` | Agent 结果反馈批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询最新反馈。 |
676
- | `GET /api/agent/page-requests/{id}/feedback` | Agent 结果反馈只读 API | 是 | 按页面 `clientRequestId` 查询最新反馈。 |
677
- | `POST /api/agent/diagnostics/page-requests` | Agent 日志诊断批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询脱敏日志摘要。 |
678
- | `GET /api/agent/diagnostics/page-requests/{id}` | Agent 日志诊断摘要 API | 是 | 按页面 `clientRequestId` 查询脱敏日志摘要,不直接读取 `/api/logs` SSE。 |
679
- | `POST /api/shares`、`GET /api/shares/{token}`、`GET/POST /api/shares/{token}/content` | 分享访问 API | 否 | `POST /api/shares` 是页面上传创建端点,不进入 Agent OpenAPI���`GET/POST /content` 使用分享 token 或访问码服务用户浏览器,不复用 Agent artifact 下载契约。Agent 只通过 `/api/agent/artifacts/{id}/share` 创建这类分享记录。 |
680
- | `GET /api/logs` | 页面日志 SSE API | 否 | 使用页面访问码哈希的 Bearer 头,不接受 `AGENT_API_TOKEN`。 |
681
- | `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent 状态库 artifact。 |
682
- | 灵感相册 | 浏览器本地工作台状态 | 否 | 只服务页面提示词复用,不作为 Agent capabilities。 |
683
- | 历史复用 | 浏览器本地历史状态 | 否 | 只服务页面继续编辑、做变体和复用提示词。 |
684
 
685
  ## 错误
686
 
@@ -688,39 +698,39 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
688
 
689
  ```json
690
  {
691
- "error": {
692
- "code": "validation_error",
693
- "message": "请求校验失败。",
694
- "retryable": false,
695
- "details": {
696
- "fields": {
697
- "n": "必须是 1 到 10 之间的整数"
698
- }
699
- },
700
- "diagnostics": {
701
- "elapsed_ms": 1234,
702
- "selected_channel_id": "default",
703
- "upstream_host": "api.example.test",
704
- "upstream_status": 524,
705
- "upstream_event_type": "image_generation.partial_image",
706
- "partial_image_count": 1,
707
- "transport_error": false,
708
- "transport_error_kind": "upstream_timeout",
709
- "retry_after_seconds": 15,
710
- "retry_after_ms": 15000,
711
- "cooldown_until": "2026-05-20T00:00:15.000Z",
712
- "cooldown_target": {
713
- "channel_id": "default",
714
- "request_mode": "images-sse"
715
- },
716
- "channel_cooldown_scope": "channel",
717
- "response_headers": {
718
- "date": "Wed, 20 May 2026 00:00:00 GMT",
719
- "cf-ray": "example"
720
- }
721
- },
722
- "request_id": "uuid"
723
- }
724
  }
725
  ```
726
 
 
23
  生成、编辑、批量和上游诊断都应先使用这些内置脚本;不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现同一套 API 调用。
24
 
25
  - `scripts/generate-image.mjs`:JSON 文生图调用。
26
+ - `scripts/edit-image.mjs`:multipart 编辑调用;固定尺寸任务可添加 `--dimension-check` 验收真实产物尺寸
27
  - `scripts/batch-images.mjs`:JSONL 批量 generate/edit 调用。
28
  - `scripts/convert-image-format.mjs`:本地 PNG/JPEG/WebP 互转。
29
  - `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
 
43
  npm run env:summary
44
  npm run env:summary -- --file .env.local --container gpt-image-playground-customer
45
  ```
46
+
47
  当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。
48
  同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
49
  页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
50
  脚本会在 dry-run 和真实请求前前置校验 `--size` 或 JSONL `size`。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量和上游探针默认请求 `output_format=webp`、`output_compression=100`。
51
+ 真实执行输出会包含机器可读 `summary`。成功摘要包含 `ok`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`share_urls`、`direct_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。`transport` 表示 Agent 对外访问的服务端端点形态,`route_mode` 表示 Agent/job/page SSE 路径,`channel_request_mode` 表示服务端实际调用上游的 Images/Responses 与 SSE/非流式组合,`route_decision` 记录 requested backend、preferred/fallback/selected request mode、fallback 是否发生、选中渠道、上游 host 或 no-channel 原因。`share_urls` 只在显式 `--share` 后出现,用于给用户浏览器打开分享页;`direct_content_urls` 只在显式 `--share` 后出现,用于分享后的内容直链;公开分享可直接打开 `direct_content_urls`,设置访问码时优先给用户 `share_urls`;`content_urls` 仍是需要 Agent 鉴权的 artifact 下载路径。失败摘要也稳定包含空数组或 `null` 形式的产物、路由、渠道和尺寸字段,便于 subagent 按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;渠道与路由诊断优先读取 `error.diagnostics`,没有对应诊断字段时才回退到响应里的 `execution`。Agent JSON 失败时脚本会按幂等键只读查询 Agent state;若命中,会把 `request_id`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。
52
 
53
  生成脚本参数:
54
 
 
69
  - `--partial-images`:可选,显式设置上游 SSE partial image 数量。generate 或页面 SSE 请求包含 `image_backend` 时优先按 capabilities 的 `limits.partial_images_by_backend[image_backend]` 校验;缺少 backend 专属范围时才使用 `limits.partial_images`。
70
  - `--share`:真实生图成功后,为每个 Agent artifact 调用 `POST /api/agent/artifacts/{id}/share` 创建用户可打开的分享链接,并在顶层 `shares`、`summary.share_urls` 和 `summary.direct_content_urls` 输出结果。
71
  - `--share-expires-minutes`:可选,设置分享有效期分钟数;省略时使用服务端默认值。
72
+ - `--dimension-check`:读取响应 `b64_json` 或同 origin `absolute_content_url`/`content_url`/`absolute_path`/`path`,校验 PNG/JPEG/WebP 尺寸等于 `--size`;通过时 summary 写入实际尺寸,失败时写入 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`expected_dimensions` 和 `actual_dimensions`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
73
  - `GPT_IMAGE_SHARE_ACCESS_CODE`:可选,创建需要访问码的分享链接;访问码不会出现在返回 URL 中,也不会出现在命令行参数里。
74
  - `--timeout-ms`:未显式指定时,脚本先用 `420000ms` 读取 capabilities;真实请求会采用 `420000ms` 与 `capabilities.image_transport.upstream_timeout_ms` 中较大的值。
75
  - `--prompt-file`:从文本文件读取 prompt。
 
105
  - `--sse-log`
106
  - `--timeout-ms`
107
  - `--idempotency-key`
108
+ - `--dimension-check`
109
  - `--page-sse`
110
  - `--agent`
111
  - `--dry-run`
 
113
 
114
  图片路径可以用位置参数 `<image-path> <prompt>`,也可以用 `--image <path> <prompt>`;两者不能同时设置。
115
  默认 WebP edit 走页面端 `/api/images` form-data SSE,因为 Agent edit 不接收输出格式字段。显式 `--format`、`--output-compression`、`--image-backend responses-image-generation`、页面高级字段或 `--page-sse` 也会走页面 SSE;失败后脚本输出结构化失败和备用端点建议,不会在同一次请求里静默二次调用。
116
+ 显式 `--page-sse` 会强制页面流式;显式 `--agent` 会走 Agent edit 最终 JSON。Agent edit 不接受 `image_backend`、`output_format` 或 `output_compression`;强制 Agent edit 时输出格式固定为 PNG,`partial_images` 按默认 Images API/profile 范围校验。Agent edit 只是页面 SSE 失败后的显式对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须使用生成/编辑/批量 `--dimension-check` 或下载后校验。编辑 `--dimension-check` 会读取响应 `b64_json` 或同 origin `content_url`,通过时在图片和 summary 写入实际尺寸,失败时输出结构化 `dimension_check_failed`、`generated_artifact_failed_dimension_check`、产物 URL 和服务端选路摘要。Responses image_generation edit 必须走页面 SSE,可显式设置 `--page-sse --image-backend responses-image-generation --streaming-strategy responses-sse`。如果运行时已显式配置 `IMAGE_GENERATION_BACKEND=responses-image-generation` 或兼容别名 `responses`,且 `IMAGE_STREAMING_STRATEGY=responses-sse`,也可依赖服务端默认值;Docker compose 本身不设置这两个默认值,未配置 `.env.local` 时仍是 `images-api` 和 `auto`。默认 WebP edit 与 `stream_mode=non_stream` / `streaming_strategy=off` 冲突时脚本会前置拒绝;需要 Agent JSON 对照时必须显式添加 `--agent`,并使用新的 `Idempotency-Key`。
117
 
118
  批量脚本参数:
119
 
 
121
  - `--manifest`:append-only JSONL manifest 路径,默认 `<input>.manifest.jsonl`。
122
  - `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。
123
  - `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
124
+ - `--dimension-check`:读取响应 `b64_json` 或同 origin `absolute_content_url`/`content_url`/`absolute_path`/`path`,校验 PNG/JPEG/WebP 尺寸等于任务 `size`;通过时 summary 写入实际尺寸,失败时写入 `error.code=dimension_check_failed`、`validation_failure_kind=generated_artifact_failed_dimension_check`、产物 URL、`expected_dimensions` 和 `actual_dimensions`。这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
125
  - `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
126
  - `--concurrency`:并发执行窗口,默认 `1`。大于 `1` 时会先读取 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`,把有效并发限制到服务端建议值后按输入顺序输出结果;适合已确认渠道容量的批量生产。
127
  - `--max-consecutive-failures`:顺序执行下的连续失败熔断阈值,默认 `0` 表示不熔断。只能与 `--concurrency 1` 同用。
 
136
  Responses edit JSONL 正例:
137
 
138
  ```jsonl
139
+ {
140
+ "id": "edit-responses",
141
+ "mode": "edit",
142
+ "prompt": "replace the background",
143
+ "image_path": "source.png",
144
+ "image_backend": "responses-image-generation",
145
+ "streaming_strategy": "responses-sse",
146
+ "partial_images": 1
147
+ }
148
  ```
149
 
150
  dry-run 预期:`routing.transport=page_sse`、`endpoint=/api/images`、`request.image_backend=responses-image-generation`。
 
152
  缺少 `image_backend` 的反例:
153
 
154
  ```jsonl
155
+ {
156
+ "id": "edit-responses-missing-backend",
157
+ "mode": "edit",
158
+ "prompt": "replace the background",
159
+ "image_path": "source.png",
160
+ "responsesModel": "gpt-4.1"
161
+ }
162
  ```
163
 
164
  dry-run 预期退出码为 `2`,错误包含 `responsesModel 必须同时设置 image_backend=responses-image-generation`。
 
246
  - `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
247
 
248
  新增 probe、diagnostics 或健康摘要时,先把机器契约放进 capabilities、OpenAPI 或明确的 Agent 只读端点,再让脚本消费这些字段;不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑。
249
+
250
  - `defaults.image_backend`:Agent generate 默认 `images-api`。
251
  - `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。
252
  - `defaults.streaming_strategy`:Agent generate 默认 `auto`。
 
283
 
284
  ```json
285
  {
286
+ "job": {
287
+ "id": "job-request-uuid",
288
+ "request_id": "job-request-uuid",
289
+ "idempotency_key": "stable-key",
290
+ "mode": "generate",
291
+ "state": "running",
292
+ "created_at": "2026-05-20T00:00:00.000Z",
293
+ "updated_at": "2026-05-20T00:00:00.000Z",
294
+ "expires_at": "2026-05-21T00:00:00.000Z",
295
+ "result_url": "/api/agent/jobs/job-request-uuid/result",
296
+ "retry_after_seconds": 5
297
+ }
298
  }
299
  ```
300
 
 
340
 
341
  ```json
342
  {
343
+ "prompt": "a product photo of a ceramic mug",
344
+ "model": "gpt-image-2",
345
+ "n": 1,
346
+ "size": "1024x1024",
347
+ "quality": "high",
348
+ "output_format": "webp",
349
+ "output_compression": 100,
350
+ "background": "auto",
351
+ "moderation": "auto",
352
+ "response_mode": "path",
353
+ "image_backend": "images-api",
354
+ "stream_mode": "auto",
355
+ "streaming_strategy": "auto",
356
+ "partial_images": 2
357
  }
358
  ```
359
 
 
371
 
372
  ```json
373
  {
374
+ "request_id": "uuid",
375
+ "idempotency_key": "stable-key",
376
+ "cached": false,
377
+ "images": [
378
+ {
379
+ "id": "artifact-uuid",
380
+ "filename": "1715400000000-abcdef1234567890-0.webp",
381
+ "content_url": "/api/agent/artifacts/artifact-uuid/content",
382
+ "metadata_url": "/api/agent/artifacts/artifact-uuid",
383
+ "output_format": "webp",
384
+ "mime_type": "image/webp",
385
+ "size_bytes": 12345,
386
+ "width": 1024,
387
+ "height": 1024
388
+ }
389
+ ],
390
+ "usage": {},
391
+ "created_at": "2026-05-12T00:00:00.000Z",
392
+ "timing": {
393
+ "started_at": "2026-05-12T00:00:00.000Z",
394
+ "completed_at": "2026-05-12T00:01:04.000Z",
395
+ "elapsed_ms": 64000,
396
+ "server_elapsed_ms": 64000
397
+ },
398
+ "execution": {
399
+ "transport": "agent_job_polling",
400
+ "endpoint": "/api/agent/image-requests",
401
+ "route_mode": "job",
402
+ "operation": "generate",
403
+ "image_backend": "images-api",
404
+ "stream_mode": "non_stream",
405
+ "streaming_strategy": "off",
406
+ "selected_channel_id": "default",
407
+ "upstream_host": "api.example.test",
408
+ "request_headers": {
409
+ "user_agent_effective": "gpt-image-playground/2.1.0",
410
+ "has_extra_headers": false,
411
+ "allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
412
+ "configured_header_names": []
413
+ }
414
  }
 
415
  }
416
  ```
417
 
 
491
 
492
  ```json
493
  {
494
+ "target": { "type": "page_request", "id": "stable-operation-key" },
495
+ "feedback": {
496
+ "target_type": "page_request",
497
+ "target_id": "stable-operation-key",
498
+ "value": "usable",
499
+ "source": "webui",
500
+ "updated_at": "2026-05-12T00:00:00.000Z",
501
+ "note": "approved"
502
+ }
503
  }
504
  ```
505
 
 
509
 
510
  ```json
511
  {
512
+ "ids": ["stable-operation-key", "stable-operation-key-2"]
513
  }
514
  ```
515
 
 
519
 
520
  ```json
521
  {
522
+ "ids": ["stable-operation-key", "stable-operation-key-2"],
523
+ "filenames": ["output.png"]
524
  }
525
  ```
526
 
 
530
 
531
  ```json
532
  {
533
+ "scope": {
534
+ "request_ids": ["stable-operation-key"],
535
+ "filenames": ["output.png"],
536
+ "filename_matched_request_ids": [],
537
+ "copy_text": "requestIds=stable-operation-key filename=output.png"
538
+ },
539
+ "matched_log_count": 0,
540
+ "events": [],
541
+ "diagnostics_retention": {
542
+ "storage": "bounded_local_jsonl",
543
+ "max_entries": 300,
544
+ "default_max_entries": 300,
545
+ "min_entries": 100,
546
+ "max_configured_entries": 5000,
547
+ "configured_by": "APP_LOG_MAX_ENTRIES",
548
+ "persisted_across_process_restart": true,
549
+ "loss_modes": ["entry_evicted_by_max_entries", "log_level_filter", "local_log_file_missing_or_cleared"],
550
+ "bounded": true,
551
+ "not_agent_state_backend": true
552
+ },
553
+ "diagnostics_note": {
554
+ "code": "no_matching_logs_in_retention_window",
555
+ "message": "没有匹配到页面请求日志;诊断只覆盖最近 300 条本地应用日志,日志可能已被保留条数淘汰、被日志级别过滤,或本地日志文件被清理。",
556
+ "retention": {
557
+ "storage": "bounded_local_jsonl",
558
+ "max_entries": 300,
559
+ "default_max_entries": 300,
560
+ "min_entries": 100,
561
+ "max_configured_entries": 5000,
562
+ "configured_by": "APP_LOG_MAX_ENTRIES",
563
+ "persisted_across_process_restart": true,
564
+ "loss_modes": ["entry_evicted_by_max_entries", "log_level_filter", "local_log_file_missing_or_cleared"],
565
+ "bounded": true,
566
+ "not_agent_state_backend": true
567
+ }
 
 
 
 
 
 
 
568
  }
 
569
  }
570
  ```
571
 
 
591
 
592
  首次配置和诊断输出字段速查:
593
 
594
+ | 字段 | 出现位置 | 判断口径 |
595
+ | -------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
596
+ | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
597
+ | `service_base_url_source` / `verification_scope.service_base_url_source` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | `user_provided` 表示用户或命令行明确指定;`GPT_IMAGE_PLAYGROUND_URL` 表示来自环境变量;`default_local_probe` 表示默认本地探测。 |
598
+ | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
599
+ | `agent_auth_process.has_token` | `first-run --json` | 当前进程是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
600
+ | `page_sse_auth_available_to_process` | `first-run --json` | 目标服务要求页面 SSE `passwordHash` 时,当前进程是否已加载 `GPT_IMAGE_APP_PASSWORD_HASH`。 |
601
+ | `summary.page_sse_auth_ready` | `agent:doctor` | 页面 SSE 鉴权是否已满足;为 `false` 时不要运行 `--page-sse` 真实计费请求。 |
602
+ | `page_sse_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 `/api/images` smoke;`state=not_run` 且 `billable=false` 表示它只是只读就绪检查。 |
603
+ | `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
604
+ | `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
605
+ | `summary.orchestration_generate_smoke` | `agent:doctor` | `--allow-billable` 时默认 generate 主链 `/api/agent/image-requests` 的真实 smoke 状态;这是普通 generate 在 server-owned orchestration 下的主编排口径。 |
606
+ | `summary.agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时显式 `--agent` 的 Agent JSON 文生图 smoke 状态;用于诊断直连 Agent JSON,不代表默认主链。 |
607
+ | `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
608
+ | `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
609
+ | `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `orchestration_generate_1k`、`agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
610
+ | `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要,包含 `supported`、`configured`、`effective`、`admin_whitelist_by_channel`、`effective_by_channel`、带 `severity` 的 `gaps`、`suggested_channel_env_key`、`suggested_effective_value` 和 `next_action`;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
611
+ | `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
612
+ | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
613
+ | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
614
+ | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口��无匹配日志不等于请求一定没发生。 |
615
 
616
  单条 Agent state 诊断响应示例:
617
 
618
  ```json
619
  {
620
+ "found": true,
621
+ "diagnostics": {
622
+ "request": {
623
+ "request_id": "req_abc",
624
+ "idempotency_key": "stable-operation-key",
625
+ "mode": "generate",
626
+ "status": "succeeded",
627
+ "cached": false,
628
+ "created_at": "2026-05-12T00:00:00.000Z",
629
+ "updated_at": "2026-05-12T00:01:04.000Z",
630
+ "expires_at": "2026-05-13T00:00:00.000Z"
631
+ },
632
+ "response": {
633
+ "image_count": 1,
634
+ "artifact_ids": ["artifact-uuid"],
635
+ "content_urls": ["/api/agent/artifacts/artifact-uuid/content"],
636
+ "timing": {
637
+ "elapsed_ms": 64000,
638
+ "server_elapsed_ms": 64000
639
+ },
640
+ "execution": {
641
+ "transport": "agent_json",
642
+ "endpoint": "/api/agent/images/generate",
643
+ "request_headers": {
644
+ "user_agent_effective": "gpt-image-playground/2.1.0",
645
+ "has_extra_headers": false,
646
+ "allowed_header_names": ["user-agent", "x-app-id", "x-app-secret"],
647
+ "configured_header_names": []
648
+ }
649
+ }
650
+ },
651
+ "state_backend": "sqlite",
652
+ "diagnostics_retention": {
653
+ "storage": "agent_state",
654
+ "ttl_seconds": 86400,
655
+ "bounded": true,
656
+ "loss_modes": ["request_expired_by_ttl", "artifact_deleted_or_purged", "state_backend_reset"]
657
  }
 
 
 
 
 
 
 
 
658
  }
 
659
  }
660
  ```
661
 
 
676
 
677
  ### 边界矩阵
678
 
679
+ | 前端能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化口径 |
680
+ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
681
+ | `POST /api/agent/image-requests`、`POST /api/agent/images/generate`、`POST /api/agent/images/edit`、Agent jobs、Agent artifacts、`POST /api/agent/artifacts/{id}/share` | Agent API | 是 | 普通 generate 默认用 image-requests;其他 Agent 端点通过 skill 脚本和 Agent 鉴权调用。分享创建需要 Agent 鉴权,返回的分享 URL 给用户浏览器访问。 |
682
+ | `POST /api/images` | 页面 form-data SSE API | 否 | 仅在默认 WebP edit、复杂 UI 批量、页面高级字段或显式 `--page-sse` 诊断时由 skill 选择。 |
683
+ | `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 页面展示运行态默认值、图片上游传输配置、渠道健康和后端 enablement;不是 Agent capabilities。 |
684
+ | `PUT/DELETE /api/feedback` | 页面结果反馈写入和清理 API | 否 | 页面写入最近生成的结果反馈;删除历史时清理对应反馈。 |
685
+ | `POST /api/agent/page-requests/feedback` | Agent 结果反馈批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询最新反馈。 |
686
+ | `GET /api/agent/page-requests/{id}/feedback` | Agent 结果反馈只读 API | 是 | 按页面 `clientRequestId` 查询最新反馈。 |
687
+ | `POST /api/agent/diagnostics/page-requests` | Agent 日志诊断批量只读 API | 是 | 按多个页面 `clientRequestId` 批量查询脱敏日志摘要。 |
688
+ | `GET /api/agent/diagnostics/page-requests/{id}` | Agent 日志诊断摘要 API | 是 | 按页面 `clientRequestId` 查询脱敏日志摘要,不直接读取 `/api/logs` SSE。 |
689
+ | `POST /api/shares`、`GET /api/shares/{token}`、`GET/POST /api/shares/{token}/content` | 分享访问 API | 否 | `POST /api/shares` 是页面上传创建端点,不进入 Agent OpenAPI`GET/POST /content` 使用分享 token 或访问码服务用户浏览器,不复用 Agent artifact 下载契约。Agent 只通过 `/api/agent/artifacts/{id}/share` 创建这类分享记录。 |
690
+ | `GET /api/logs` | 页面日志 SSE API | 否 | 使用页面访问码哈希的 Bearer 头,不接受 `AGENT_API_TOKEN`。 |
691
+ | `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent 状态库 artifact。 |
692
+ | 灵感相册 | 浏览器本地工作台状态 | 否 | 只服务页面提示词复用,不作为 Agent capabilities。 |
693
+ | 历史复用 | 浏览器本地历史状态 | 否 | 只服务页面继续编辑、做变体和复用提示词。 |
694
 
695
  ## 错误
696
 
 
698
 
699
  ```json
700
  {
701
+ "error": {
702
+ "code": "validation_error",
703
+ "message": "请求校验失败。",
704
+ "retryable": false,
705
+ "details": {
706
+ "fields": {
707
+ "n": "必须是 1 到 10 之间的整数"
708
+ }
709
+ },
710
+ "diagnostics": {
711
+ "elapsed_ms": 1234,
712
+ "selected_channel_id": "default",
713
+ "upstream_host": "api.example.test",
714
+ "upstream_status": 524,
715
+ "upstream_event_type": "image_generation.partial_image",
716
+ "partial_image_count": 1,
717
+ "transport_error": false,
718
+ "transport_error_kind": "upstream_timeout",
719
+ "retry_after_seconds": 15,
720
+ "retry_after_ms": 15000,
721
+ "cooldown_until": "2026-05-20T00:00:15.000Z",
722
+ "cooldown_target": {
723
+ "channel_id": "default",
724
+ "request_mode": "images-sse"
725
+ },
726
+ "channel_cooldown_scope": "channel",
727
+ "response_headers": {
728
+ "date": "Wed, 20 May 2026 00:00:00 GMT",
729
+ "cf-ray": "example"
730
+ }
731
+ },
732
+ "request_id": "uuid"
733
+ }
734
  }
735
  ```
736
 
skills/gpt-image-playground-agent/scripts/batch-images.mjs CHANGED
The diff for this file is too large to render. See raw diff
 
skills/gpt-image-playground-agent/scripts/edit-image.mjs CHANGED
@@ -1,48 +1,55 @@
1
  #!/usr/bin/env node
2
- import crypto from 'node:crypto';
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
  import {
6
- errorMessage,
7
- assertValidImageSizeForModel,
8
- normalizeOutputFormat,
9
- parseRetryAfterValue,
10
- readCapabilitiesImageTransportTimeoutMs,
11
- readConfiguredPositiveInteger,
12
- readMaxImageEdge,
13
- readOptionValue,
14
- readPartialImages,
15
- loadPrivateAgentEnvFile,
16
- resolvePlaygroundBaseUrl,
17
- sleep,
18
- validateAgentEditRequestAgainstCapabilities
19
- } from './lib/script-utils.mjs';
20
  import {
21
- attachSummary,
22
- buildFailureSummary,
23
- buildSuccessSummary,
24
- completeScriptTiming,
25
- startScriptTiming
 
 
 
 
 
 
 
 
 
26
  } from './lib/script-summary.mjs';
27
  import {
28
- PAGE_SSE_ENDPOINT,
29
- assertPageSseReady,
30
- assertPageSseStreamingAllowed,
31
- buildPageSseFailureOutput,
32
- formatPageSseOutput,
33
- normalizeImageBackendForPage,
34
- postPageSse
35
- } from './lib/page-sse-client.mjs';
36
- import { enrichFailureWithAgentDiagnostics } from './lib/agent-diagnostics-summary.mjs';
 
 
 
 
 
 
 
 
 
37
 
38
  const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']);
39
  const STREAMING_STRATEGIES = new Set([
40
- 'off',
41
- 'auto',
42
- 'openai-sse',
43
- 'newapi-keepalive-sse',
44
- 'responses-sse',
45
- 'force-sse'
46
  ]);
47
  const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
48
  const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
@@ -50,6 +57,9 @@ const MODERATIONS = new Set(['low', 'auto']);
50
  const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']);
51
  const DEFAULT_PAGE_OUTPUT_FORMAT = 'webp';
52
  const DEFAULT_PAGE_OUTPUT_COMPRESSION = 100;
 
 
 
53
 
54
  loadPrivateAgentEnvFile();
55
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
@@ -58,691 +68,774 @@ const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || proc
58
  const scriptTiming = startScriptTiming();
59
  let options;
60
  try {
61
- options = parseArgs(process.argv.slice(2));
62
  } catch (error) {
63
- console.error(errorMessage(error));
64
- printUsage();
65
- process.exit(2);
66
  }
67
  const imagePath = options.imagePath;
68
  const prompt = options.promptParts.join(' ');
69
  if (options.help) {
70
- printUsage();
71
- process.exit(0);
72
  }
73
 
74
  try {
75
- validateUpstreamStreamingOptions(options);
76
- options.size = assertValidImageSizeForModel(options.size, options.model, '--size');
77
  } catch (error) {
78
- console.error(errorMessage(error));
79
- printUsage();
80
- process.exit(2);
81
  }
82
 
83
  let maxAttempts;
84
  let timeoutMs;
85
  try {
86
- maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3);
87
- timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
 
 
 
 
88
  } catch (error) {
89
- console.error(errorMessage(error));
90
- printUsage();
91
- process.exit(2);
92
  }
93
- const idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`;
 
94
 
95
  if ((!imagePath || !prompt) && !contractCheck) {
96
- printUsage();
97
- process.exit(2);
98
  }
99
 
100
  let baseUrl;
101
  let baseUrlInfo;
102
  try {
103
- baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
104
- baseUrl = baseUrlInfo.baseUrl;
105
  } catch (error) {
106
- console.error(errorMessage(error));
107
- process.exit(2);
108
  }
109
 
110
  if (options.dryRun || (!contractCheck && !options.allowBillable)) {
111
- const routingGuidance = buildEditRoutingGuidance(options);
112
- console.log(
113
- JSON.stringify(
114
- {
115
- ok: true,
116
- billable: false,
117
- dry_run: true,
118
- verification_scope: buildDryRunVerificationScope(),
119
- endpoint: `${baseUrl}${routingGuidance.recommended_endpoint}`,
120
- routing_guidance: routingGuidance,
121
- idempotency_key: idempotencyKey,
122
- request: {
123
- image_path: imagePath,
124
- prompt,
125
- model: options.model,
126
- size: options.size,
127
- quality: options.quality,
128
- response_mode: options.responseMode,
129
- ...(options.streamMode ? { stream_mode: options.streamMode } : {}),
130
- ...(options.streamingStrategy ? { streaming_strategy: options.streamingStrategy } : {}),
131
- ...(options.partialImages !== undefined
132
- ? { partial_images: readPartialImages(options.partialImages, '--partial-images') }
133
- : {}),
134
- ...(usesPageOutputOptions(options, routingGuidance) ? { output_format: readOutputFormat(options) } : {}),
135
- ...(usesPageOutputOptions(options, routingGuidance) && readOutputCompression(options) !== undefined
136
- ? { output_compression: readOutputCompression(options) }
137
- : {}),
138
- ...(options.moderation ? { moderation: options.moderation } : {}),
139
- ...(options.imageBackend ? { image_backend: normalizeImageBackendForPage(options.imageBackend) } : {}),
140
- ...(options.responsesModel ? { responsesModel: readNonEmptyString(options.responsesModel, '--responses-model') } : {}),
141
- ...(options.thinking ? { thinking: options.thinking } : {}),
142
- ...(options.promptOptimization !== undefined
143
- ? { promptOptimization: readBooleanOption(options.promptOptimization, '--prompt-optimization') }
144
- : {}),
145
- ...(options.forceWeb !== undefined ? { force_web: true } : {}),
146
- ...(readEditNormalizations(options) ? { normalizations: readEditNormalizations(options) } : {})
147
- },
148
- next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。'
149
- },
150
- null,
151
- 2
152
- )
153
- );
154
- process.exit(0);
 
 
 
 
 
 
 
155
  }
156
 
157
  const routingGuidance = buildEditRoutingGuidance(options);
158
  if (routingGuidance.transport === 'page_sse') {
159
- try {
160
- assertPageSseStreamingAllowed(options);
161
- } catch (error) {
162
- console.error(errorMessage(error));
163
- process.exit(2);
164
- }
165
  }
166
 
167
  function parseArgs(argv) {
168
- const parsed = {
169
- model: 'gpt-image-2',
170
- size: 'auto',
171
- quality: 'auto',
172
- responseMode: 'path',
173
- routeMode: 'auto',
174
- streamMode: undefined,
175
- streamingStrategy: undefined,
176
- partialImages: undefined,
177
- format: DEFAULT_PAGE_OUTPUT_FORMAT,
178
- formatSpecified: false,
179
- outputCompression: undefined,
180
- moderation: undefined,
181
- imageBackend: undefined,
182
- responsesModel: undefined,
183
- thinking: undefined,
184
- promptOptimization: undefined,
185
- forceWeb: undefined,
186
- sseLogPath: undefined,
187
- timeoutMs: undefined,
188
- baseUrl: undefined,
189
- idempotencyKey: undefined,
190
- imagePath: undefined,
191
- imagePathSource: undefined,
192
- dryRun: false,
193
- allowBillable: false,
194
- help: false,
195
- promptParts: []
196
- };
197
- for (let index = 0; index < argv.length; index += 1) {
198
- const arg = argv[index];
199
- if (arg === '--dry-run') parsed.dryRun = true;
200
- else if (arg === '--allow-billable') parsed.allowBillable = true;
201
- else if (arg === '--help' || arg === '-h') parsed.help = true;
202
- else if (arg === '--contract-check') continue;
203
- else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
204
- else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
205
- else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
206
- else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
207
- else if (arg === '--agent') parsed.routeMode = 'agent';
208
- else if (arg === '--page-sse') parsed.routeMode = 'page_sse';
209
- else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(argv, (index += 1), arg);
210
- else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg);
211
- else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg);
212
- else if (arg === '--format' || arg === '--output-format') {
213
- parsed.format = readOptionValue(argv, (index += 1), arg);
214
- parsed.formatSpecified = true;
215
- }
216
- else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(argv, (index += 1), arg);
217
- else if (arg === '--moderation') parsed.moderation = readOptionValue(argv, (index += 1), arg);
218
- else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg);
219
- else if (arg === '--responses-model' || arg === '--gpt-model') parsed.responsesModel = readOptionValue(argv, (index += 1), arg);
220
- else if (arg === '--thinking') parsed.thinking = readOptionValue(argv, (index += 1), arg);
221
- else if (arg === '--prompt-optimization') parsed.promptOptimization = readOptionValue(argv, (index += 1), arg);
222
- else if (arg === '--force-web') parsed.forceWeb = true;
223
- else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg);
224
- else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
225
- else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
226
- else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
227
- else if (arg === '--image') {
228
- if (parsed.imagePathSource === 'option') throw new Error('--image 只能设置一次。');
229
- if (parsed.imagePath) throw new Error('--image 与位置参数 <image-path> 不能同时设置。');
230
- parsed.imagePath = readOptionValue(argv, (index += 1), arg);
231
- parsed.imagePathSource = 'option';
232
- }
233
- else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
234
- else if (!parsed.imagePath) {
235
- parsed.imagePath = arg;
236
- parsed.imagePathSource = 'positional';
237
- }
238
- else parsed.promptParts.push(arg);
239
- }
240
- return parsed;
241
  }
242
 
243
  function authHeaders() {
244
- if (token) return { Authorization: `Bearer ${token}` };
245
- if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
246
- return {};
247
  }
248
 
249
  function absoluteUrl(value) {
250
- if (typeof value !== 'string' || !value) return undefined;
251
- return new URL(value, `${baseUrl}/`).toString();
252
  }
253
 
254
  function buildDryRunVerificationScope() {
255
- return {
256
- mode: 'local_planning_only',
257
- service_base_url: baseUrl,
258
- service_base_url_source: baseUrlInfo.source,
259
- interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
260
- remote_capabilities_verified: false,
261
- runtime_capacity_verified: false,
262
- auth_verified: false,
263
- billable_request_sent: false,
264
- note: 'Dry-run validates local request construction and routing guidance only; run --contract-check or --allow-billable to verify the remote service.'
265
- };
266
  }
267
 
268
  function buildEditRoutingGuidance(parsed) {
269
- if (parsed.routeMode === 'agent') {
270
- assertNoPageOnlyEditOptions(parsed, 'Agent edit');
271
- return {
272
- recommended_endpoint: '/api/agent/images/edit',
273
- transport: 'agent_json',
274
- strength: 'default',
275
- reason: 'Explicit --agent requests use the Agent JSON edit response contract.'
276
- };
277
- }
278
- if (parsed.routeMode === 'page_sse') {
279
- return {
280
- recommended_endpoint: '/api/images',
281
- transport: 'page_sse',
282
- strength: 'default',
283
- reason: 'Explicit --page-sse requests use the page form-data SSE endpoint.'
284
- };
285
- }
286
- if (hasPageOnlyEditOptions(parsed) && isPageSseAllowed(parsed)) {
287
- return {
288
- recommended_endpoint: '/api/images',
289
- transport: 'page_sse',
290
- strength: 'default',
291
- reason: 'GPT2Image-compatible edit options require the page form-data SSE endpoint; Agent JSON edit does not accept those fields.'
292
- };
293
- }
294
- if (usesDefaultPageOutput(parsed) && isPageSseAllowed(parsed)) {
295
- return {
296
- recommended_endpoint: '/api/images',
297
- transport: 'page_sse',
298
- strength: 'default',
299
- reason: 'Default WebP edit output uses the page form-data SSE endpoint; Agent JSON edit has a fixed output contract.'
300
- };
301
- }
302
- if (readMaxImageEdge(parsed.size) > 2048 && isPageSseAllowed(parsed)) {
 
 
 
 
 
 
 
303
  return {
304
- recommended_endpoint: '/api/images',
305
- transport: 'page_sse',
306
- strength: 'default',
307
- reason: 'High-resolution edit defaults to the page form-data SSE endpoint; if streaming has issues, diagnose first and explicitly fall back to Agent edit.'
308
  };
309
- }
310
- return {
311
- recommended_endpoint: '/api/agent/images/edit',
312
- transport: 'agent_json',
313
- strength: 'default',
314
- reason: 'Agent edit uses a fixed output contract and should be selected explicitly with --agent.'
315
- };
316
  }
317
 
318
  function enrichImageUrls(result) {
319
- if (!result || !Array.isArray(result.images)) return result;
320
- return {
321
- ...result,
322
- images: result.images.map((image) => ({
323
- ...image,
324
- ...(image.content_url ? { absolute_content_url: absoluteUrl(image.content_url) } : {}),
325
- ...(image.metadata_url ? { absolute_metadata_url: absoluteUrl(image.metadata_url) } : {})
326
- }))
327
- };
328
  }
329
 
330
  async function readCapabilities() {
331
- let response;
332
- try {
333
- response = await fetchWithTimeout(`${baseUrl}/api/agent/capabilities`, {
334
- headers: authHeaders()
335
- });
336
- } catch (error) {
337
- const message = errorMessage(error);
338
- throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
339
- }
340
- if (!response.ok) {
341
- const body = await response.text();
342
- throw new Error(`capabilities 请求失败,状态码 ${response.status}:${body}`);
343
- }
344
- return response.json();
345
  }
346
 
347
  function assertPageSseReadyForEdit(capabilities) {
348
- assertPageSseReady({
349
- capabilities,
350
- passwordHash,
351
- idempotencyKey
352
- });
353
  }
354
 
355
  function shouldRetry(result) {
356
- return Boolean(result?.error?.retryable);
357
  }
358
 
359
  function validateUpstreamStreamingOptions(parsed) {
360
- if (parsed.streamMode && !STREAM_MODES.has(parsed.streamMode)) {
361
- throw new Error('--stream-mode 必须是 auto、stream 或 non_stream。');
362
- }
363
- if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) {
364
- throw new Error(
365
- '--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'
366
- );
367
- }
368
- if (parsed.routeMode === 'page_sse') {
369
- assertPageSseStreamingAllowed(parsed);
370
- }
371
- if (hasPageOnlyEditOptions(parsed) && !isPageSseAllowed(parsed)) {
372
- throw new Error('图生图高级参数需要页面 SSE,不能同时设置 stream_mode=non_stream 或 streaming_strategy=off。');
373
- }
374
- if (parsed.routeMode !== 'agent' && usesDefaultPageOutput(parsed) && !isPageSseAllowed(parsed)) {
375
- throw new Error('默认 WebP 图生图输出需要页面 SSE;若要使用 Agent JSON 固定输出,请添加 --agent。');
376
- }
377
- if (parsed.routeMode === 'agent') {
378
- assertNoPageOnlyEditOptions(parsed, 'Agent edit');
379
- }
380
- if (parsed.formatSpecified && !OUTPUT_FORMATS.has(readOutputFormat(parsed))) {
381
- throw new Error('--format 必须是 png、jpeg 或 webp。');
382
- }
383
- if (parsed.outputCompression !== undefined) readOutputCompression(parsed);
384
- if (parsed.moderation && !MODERATIONS.has(parsed.moderation)) {
385
- throw new Error('--moderation 必须是 low 或 auto。');
386
- }
387
- if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
388
- throw new Error('--image-backend 必须是 images-api、images、responsesresponses-image-generation。');
389
- }
390
- if (parsed.responsesModel !== undefined) readNonEmptyString(parsed.responsesModel, '--responses-model');
391
- if (parsed.thinking && !THINKING_VALUES.has(parsed.thinking)) {
392
- throw new Error('--thinking 必须是 minimal、none、low、medium、high 或 xhigh。');
393
- }
394
- if (parsed.promptOptimization !== undefined) readBooleanOption(parsed.promptOptimization, '--prompt-optimization');
395
- if (parsed.partialImages !== undefined) readPartialImages(parsed.partialImages, '--partial-images');
 
 
 
396
  }
397
 
398
  function hasPageOnlyEditOptions(parsed) {
399
- return Boolean(
400
- parsed.formatSpecified ||
401
- parsed.outputCompression !== undefined ||
402
- parsed.moderation ||
403
- parsed.imageBackend ||
404
- parsed.responsesModel ||
405
- parsed.thinking ||
406
- parsed.promptOptimization !== undefined ||
407
- parsed.forceWeb !== undefined
408
- );
409
  }
410
 
411
  function assertNoPageOnlyEditOptions(parsed, context) {
412
- if (!hasPageOnlyEditOptions(parsed)) return;
413
- throw new Error(`${context} 不接受图生图高级页面字段;请去掉这些字段或使用 --page-sse。`);
414
  }
415
 
416
  function readOutputFormat(parsed) {
417
- return parsed.format ? normalizeOutputFormat(parsed.format) : DEFAULT_PAGE_OUTPUT_FORMAT;
418
  }
419
 
420
  function usesPageOutputOptions(parsed, routingGuidance) {
421
- return routingGuidance.transport === 'page_sse' || parsed.formatSpecified || parsed.outputCompression !== undefined;
422
  }
423
 
424
  function usesDefaultPageOutput(parsed) {
425
- return !parsed.formatSpecified && parsed.outputCompression === undefined && readOutputFormat(parsed) === DEFAULT_PAGE_OUTPUT_FORMAT;
 
 
 
 
426
  }
427
 
428
  function readOutputCompression(parsed) {
429
- const outputFormat = readOutputFormat(parsed);
430
- if (outputFormat === 'png') return undefined;
431
- const value = parsed.outputCompression === undefined ? String(DEFAULT_PAGE_OUTPUT_COMPRESSION) : String(parsed.outputCompression);
432
- if (!/^\d+$/.test(value)) throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
433
- const parsedValue = Number(value);
434
- if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 100) {
435
- throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
436
- }
437
- return parsedValue;
 
 
 
438
  }
439
 
440
  function readEditNormalizations(parsed) {
441
- if (parsed.outputCompression === undefined || readOutputFormat(parsed) !== 'png') return undefined;
442
- return { output_compression_ignored_for_png: true };
443
  }
444
 
445
  function readBooleanOption(value, name) {
446
- if (value === true || value === 'true') return true;
447
- if (value === false || value === 'false') return false;
448
- throw new Error(`${name} 必须是 true 或 false。`);
449
  }
450
 
451
  function readNonEmptyString(value, name) {
452
- if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} 必须是非空字符串。`);
453
- return value.trim();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
454
  }
455
 
456
  function isPageSseAllowed(parsed) {
457
- return parsed.streamMode !== 'non_stream' && parsed.streamingStrategy !== 'off';
458
  }
459
 
460
  async function fetchWithTimeout(url, init) {
461
- const controller = new AbortController();
462
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
463
- try {
464
- return await fetch(url, { ...init, signal: controller.signal });
465
- } finally {
466
- clearTimeout(timeout);
467
- }
468
  }
469
 
470
  function printUsage() {
471
- console.error('用法:edit-image.mjs [options] <image-path> <prompt> 或 edit-image.mjs --image <path> [options] <prompt>');
472
- console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
473
- console.error('常用参数:--image --model --size --quality --response-mode --format --output-compression --moderation --image-backend --responses-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --sse-log --timeout-ms --base-url --idempotency-key --page-sse --agent --dry-run --allow-billable');
474
- console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
 
 
 
 
475
  }
476
 
477
  let capabilities;
478
  try {
479
- capabilities = await readCapabilities();
480
- if (options.timeoutMs === undefined) {
481
- timeoutMs = readCapabilitiesImageTransportTimeoutMs(capabilities, timeoutMs);
482
- }
483
  } catch (error) {
484
- console.error(errorMessage(error));
485
- process.exit(1);
486
  }
487
 
488
  try {
489
- validateAgentEditRequestAgainstCapabilities(
490
- {
491
- n: 1,
492
- partial_images:
493
- options.partialImages !== undefined
494
- ? readPartialImages(options.partialImages, '--partial-images')
495
- : capabilities?.defaults?.partial_images,
496
- imageCount: 1,
497
- image_backend: options.imageBackend
498
- },
499
- capabilities
500
- );
501
  } catch (error) {
502
- console.error(errorMessage(error));
503
- process.exit(2);
504
  }
505
 
506
  if (contractCheck) {
507
- const response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
508
- method: 'POST',
509
- headers: {
510
- 'Idempotency-Key': idempotencyKey,
511
- 'Content-Type': 'application/json',
512
- ...authHeaders()
513
- },
514
- body: JSON.stringify({ prompt: 'contract check' })
515
- });
516
- const result = await response.json();
517
- if (response.status === 415 && result?.error?.code === 'validation_error') {
518
- console.log(JSON.stringify({ ok: true, billable: false, status: response.status, error_code: result.error.code }, null, 2));
519
- process.exit(0);
520
- }
521
- console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
522
- process.exit(1);
 
 
 
 
 
 
523
  }
524
 
525
  try {
526
- const stats = fs.statSync(imagePath);
527
- if (!stats.isFile()) {
528
- console.error(`图片路径不是文件:${imagePath}`);
529
- process.exit(2);
530
- }
531
  } catch (error) {
532
- const message = errorMessage(error);
533
- console.error(`无法读取图片文件:${imagePath}。${message}`);
534
- process.exit(2);
535
  }
536
 
537
  const imageBuffer = fs.readFileSync(imagePath);
538
  const imageType = mimeTypeForPath(imagePath);
539
 
540
  function buildFormData() {
541
- const formData = new FormData();
542
- formData.append('prompt', prompt);
543
- formData.append('model', options.model);
544
- formData.append('size', options.size);
545
- formData.append('quality', options.quality);
546
- formData.append('response_mode', options.responseMode);
547
- if (options.streamMode) formData.append('stream_mode', options.streamMode);
548
- if (options.streamingStrategy) formData.append('streaming_strategy', options.streamingStrategy);
549
- if (options.partialImages !== undefined) {
550
- formData.append('partial_images', String(readPartialImages(options.partialImages, '--partial-images')));
551
- } else if (capabilities?.defaults?.partial_images !== undefined) {
552
- formData.append('partial_images', String(readPartialImages(capabilities.defaults.partial_images, 'capabilities.defaults.partial_images')));
553
- }
554
- formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
555
- return formData;
 
 
 
556
  }
557
 
558
  function buildPageSseFormData() {
559
- const formData = new FormData();
560
- formData.append('mode', 'edit');
561
- formData.append('prompt', prompt);
562
- formData.append('model', options.model);
563
- formData.append('size', options.size);
564
- formData.append('quality', options.quality);
565
- formData.append('response_mode', options.responseMode);
566
- formData.append('clientRequestId', idempotencyKey);
567
- formData.append('stream', 'true');
568
- formData.append('output_format', readOutputFormat(options));
569
- if (readOutputCompression(options) !== undefined) {
570
- formData.append('output_compression', String(readOutputCompression(options)));
571
- }
572
- if (options.moderation) formData.append('moderation', options.moderation);
573
- if (options.imageBackend) formData.append('image_backend', normalizeImageBackendForPage(options.imageBackend));
574
- if (options.responsesModel) formData.append('responsesModel', readNonEmptyString(options.responsesModel, '--responses-model'));
575
- if (options.thinking) formData.append('thinking', options.thinking);
576
- if (options.promptOptimization !== undefined) {
577
- formData.append('promptOptimization', String(readBooleanOption(options.promptOptimization, '--prompt-optimization')));
578
- }
579
- if (options.forceWeb !== undefined) formData.append('force_web', 'true');
580
- if (options.streamMode) formData.append('stream_mode', options.streamMode);
581
- if (options.streamingStrategy) formData.append('image_streaming_strategy', options.streamingStrategy);
582
- if (options.partialImages !== undefined) {
583
- formData.append('partial_images', String(readPartialImages(options.partialImages, '--partial-images')));
584
- } else if (capabilities?.defaults?.partial_images !== undefined) {
585
- formData.append('partial_images', String(readPartialImages(capabilities.defaults.partial_images, 'capabilities.defaults.partial_images')));
586
- }
587
- if (passwordHash) formData.append('passwordHash', passwordHash);
588
- formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
589
- return formData;
 
 
 
 
 
 
 
590
  }
591
 
592
  async function runPageSseEdit() {
593
- assertPageSseReadyForEdit(capabilities);
594
- const result = await postPageSse({
595
- url: `${baseUrl}${PAGE_SSE_ENDPOINT}`,
596
- formData: buildPageSseFormData(),
597
- timeoutMs,
598
- sseLogPath: options.sseLogPath,
599
- errorMessage
600
- });
601
- console.log(
602
- JSON.stringify(
603
- attachSummary({
604
- ...formatPageSseOutput({
605
- result,
606
- baseUrl,
607
- responseMode: options.responseMode,
608
- defaultOutputFormat: DEFAULT_PAGE_OUTPUT_FORMAT
609
- }),
610
- routing: {
611
- transport: 'page_sse',
612
- endpoint: PAGE_SSE_ENDPOINT,
613
- fallback_endpoint: '/api/agent/images/edit',
614
- fallback_mode: 'manual_after_diagnosis'
615
- }
616
- }, buildSuccessSummary({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
  result,
618
- routing: {
619
- transport: 'page_sse',
620
- endpoint: PAGE_SSE_ENDPOINT
621
- },
622
- timing: completeScriptTiming(scriptTiming),
623
- idempotencyKey,
624
- billable: true
625
- })),
626
- null,
627
- 2
628
- )
629
- );
630
  }
631
 
632
  function mimeTypeForPath(filePath) {
633
- const ext = path.extname(filePath).toLowerCase();
634
- if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
635
- if (ext === '.webp') return 'image/webp';
636
- return 'image/png';
637
  }
638
 
639
  let lastResult;
640
  let lastRetryAfter = null;
641
 
642
  if (routingGuidance.transport === 'page_sse') {
643
- try {
644
- await runPageSseEdit();
645
- process.exit(0);
646
- } catch (error) {
647
- const failureOutput = buildPageSseFailureOutput({
648
- error,
649
- fallbackEndpoint: '/api/agent/images/edit',
650
- errorMessage
651
- });
652
- console.error(
653
- JSON.stringify(
654
- attachSummary(failureOutput, buildFailureSummary({
655
- errorBody: failureOutput,
656
- routing: {
657
- transport: 'page_sse',
658
- endpoint: PAGE_SSE_ENDPOINT
659
- },
660
- timing: completeScriptTiming(scriptTiming),
661
- idempotencyKey,
662
- billable: failureOutput.billable !== false
663
- })),
664
- null,
665
- 2
666
- )
667
- );
668
- process.exit(1);
669
- }
670
  }
671
 
672
  async function buildAgentFailureOutput(output, routing) {
673
- const summary = buildFailureSummary({
674
- errorBody: output,
675
- routing,
676
- timing: completeScriptTiming(scriptTiming),
677
- idempotencyKey,
678
- billable: output?.billable !== false
679
- });
680
- const enriched = await enrichFailureWithAgentDiagnostics({
681
- baseUrl,
682
- authHeaders,
683
- idempotencyKey,
684
- failureOutput: attachSummary(output, summary),
685
- summary,
686
- timeoutMs
687
- });
688
- return attachSummary(enriched.failureOutput, enriched.summary);
689
  }
690
 
691
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
692
- let response;
693
- let result;
694
- try {
695
- response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
696
- method: 'POST',
697
- headers: {
698
- 'Idempotency-Key': idempotencyKey,
699
- ...authHeaders()
700
- },
701
- body: buildFormData()
702
- });
703
- result = await response.json();
704
- } catch (error) {
705
- const message = errorMessage(error);
706
- result = { error: { code: 'network_error', message, retryable: true } };
707
- lastResult = result;
708
- lastRetryAfter = 1;
709
- if (attempt === maxAttempts) break;
710
- await sleep(lastRetryAfter);
711
- continue;
712
- }
713
- if (response.ok) {
714
- console.log(
715
- JSON.stringify(
716
- attachSummary(
717
- enrichImageUrls(result),
718
- buildSuccessSummary({
719
- result: enrichImageUrls(result),
720
- routing: { transport: 'agent_json', endpoint: '/api/agent/images/edit' },
721
- timing: completeScriptTiming(scriptTiming),
722
- idempotencyKey,
723
- billable: true
724
- })
725
- ),
726
- null,
727
- 2
728
- )
729
- );
730
- process.exit(0);
731
- }
 
 
 
 
 
 
732
 
733
- const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
734
- lastResult = result;
735
- lastRetryAfter = retryAfter;
736
- if (!shouldRetry(result) || attempt === maxAttempts) break;
737
- await sleep(retryAfter);
738
  }
739
 
740
  const failureOutput = { ...lastResult, retry_after: lastRetryAfter };
741
  console.error(
742
- JSON.stringify(
743
- await buildAgentFailureOutput(failureOutput, { transport: 'agent_json', endpoint: '/api/agent/images/edit' }),
744
- null,
745
- 2
746
- )
747
  );
748
  process.exit(1);
 
1
  #!/usr/bin/env node
2
+ import { enrichFailureWithAgentDiagnostics } from './lib/agent-diagnostics-summary.mjs';
 
 
3
  import {
4
+ assertImageDimensions,
5
+ buildDimensionCheckFailureBody,
6
+ isDimensionCheckError,
7
+ parseExpectedDimensions
8
+ } from './lib/dimension-check.mjs';
 
 
 
 
 
 
 
 
 
9
  import {
10
+ PAGE_SSE_ENDPOINT,
11
+ assertPageSseReady,
12
+ assertPageSseStreamingAllowed,
13
+ buildPageSseFailureOutput,
14
+ formatPageSseOutput,
15
+ normalizeImageBackendForPage,
16
+ postPageSse
17
+ } from './lib/page-sse-client.mjs';
18
+ import {
19
+ attachSummary,
20
+ buildFailureSummary,
21
+ buildSuccessSummary,
22
+ completeScriptTiming,
23
+ startScriptTiming
24
  } from './lib/script-summary.mjs';
25
  import {
26
+ errorMessage,
27
+ assertValidImageSizeForModel,
28
+ normalizeOutputFormat,
29
+ parseImageSizeValue,
30
+ parseRetryAfterValue,
31
+ readCapabilitiesImageTransportTimeoutMs,
32
+ readConfiguredPositiveInteger,
33
+ readMaxImageEdge,
34
+ readOptionValue,
35
+ readPartialImages,
36
+ loadPrivateAgentEnvFile,
37
+ resolvePlaygroundBaseUrl,
38
+ sleep,
39
+ validateAgentEditRequestAgainstCapabilities
40
+ } from './lib/script-utils.mjs';
41
+ import crypto from 'node:crypto';
42
+ import fs from 'node:fs';
43
+ import path from 'node:path';
44
 
45
  const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']);
46
  const STREAMING_STRATEGIES = new Set([
47
+ 'off',
48
+ 'auto',
49
+ 'openai-sse',
50
+ 'newapi-keepalive-sse',
51
+ 'responses-sse',
52
+ 'force-sse'
53
  ]);
54
  const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
55
  const OUTPUT_FORMATS = new Set(['png', 'jpeg', 'webp']);
 
57
  const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']);
58
  const DEFAULT_PAGE_OUTPUT_FORMAT = 'webp';
59
  const DEFAULT_PAGE_OUTPUT_COMPRESSION = 100;
60
+ const DIMENSION_CHECK_URL_FIELDS = ['absolute_content_url', 'content_url', 'absolute_path', 'path'];
61
+ const EDIT_DIMENSION_CHECK_NEXT_STEP =
62
+ '确认当前编辑渠道是否支持请求尺寸,或调整任务接受实际返回尺寸;重新执行必须使用新的 Idempotency-Key。';
63
 
64
  loadPrivateAgentEnvFile();
65
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
 
68
  const scriptTiming = startScriptTiming();
69
  let options;
70
  try {
71
+ options = parseArgs(process.argv.slice(2));
72
  } catch (error) {
73
+ console.error(errorMessage(error));
74
+ printUsage();
75
+ process.exit(2);
76
  }
77
  const imagePath = options.imagePath;
78
  const prompt = options.promptParts.join(' ');
79
  if (options.help) {
80
+ printUsage();
81
+ process.exit(0);
82
  }
83
 
84
  try {
85
+ validateUpstreamStreamingOptions(options);
86
+ options.size = assertValidImageSizeForModel(options.size, options.model, '--size');
87
  } catch (error) {
88
+ console.error(errorMessage(error));
89
+ printUsage();
90
+ process.exit(2);
91
  }
92
 
93
  let maxAttempts;
94
  let timeoutMs;
95
  try {
96
+ maxAttempts = readConfiguredPositiveInteger(
97
+ process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS,
98
+ 'GPT_IMAGE_AGENT_MAX_ATTEMPTS',
99
+ 3
100
+ );
101
+ timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
102
  } catch (error) {
103
+ console.error(errorMessage(error));
104
+ printUsage();
105
+ process.exit(2);
106
  }
107
+ const idempotencyKey =
108
+ options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`;
109
 
110
  if ((!imagePath || !prompt) && !contractCheck) {
111
+ printUsage();
112
+ process.exit(2);
113
  }
114
 
115
  let baseUrl;
116
  let baseUrlInfo;
117
  try {
118
+ baseUrlInfo = resolvePlaygroundBaseUrl(options.baseUrl, process.env);
119
+ baseUrl = baseUrlInfo.baseUrl;
120
  } catch (error) {
121
+ console.error(errorMessage(error));
122
+ process.exit(2);
123
  }
124
 
125
  if (options.dryRun || (!contractCheck && !options.allowBillable)) {
126
+ const routingGuidance = buildEditRoutingGuidance(options);
127
+ console.log(
128
+ JSON.stringify(
129
+ {
130
+ ok: true,
131
+ billable: false,
132
+ dry_run: true,
133
+ verification_scope: buildDryRunVerificationScope(),
134
+ endpoint: `${baseUrl}${routingGuidance.recommended_endpoint}`,
135
+ routing_guidance: routingGuidance,
136
+ idempotency_key: idempotencyKey,
137
+ request: {
138
+ image_path: imagePath,
139
+ prompt,
140
+ model: options.model,
141
+ size: options.size,
142
+ quality: options.quality,
143
+ response_mode: options.responseMode,
144
+ ...(options.streamMode ? { stream_mode: options.streamMode } : {}),
145
+ ...(options.streamingStrategy ? { streaming_strategy: options.streamingStrategy } : {}),
146
+ ...(options.partialImages !== undefined
147
+ ? { partial_images: readPartialImages(options.partialImages, '--partial-images') }
148
+ : {}),
149
+ ...(usesPageOutputOptions(options, routingGuidance)
150
+ ? { output_format: readOutputFormat(options) }
151
+ : {}),
152
+ ...(usesPageOutputOptions(options, routingGuidance) && readOutputCompression(options) !== undefined
153
+ ? { output_compression: readOutputCompression(options) }
154
+ : {}),
155
+ ...(options.moderation ? { moderation: options.moderation } : {}),
156
+ ...(options.imageBackend
157
+ ? { image_backend: normalizeImageBackendForPage(options.imageBackend) }
158
+ : {}),
159
+ ...(options.responsesModel
160
+ ? { responsesModel: readNonEmptyString(options.responsesModel, '--responses-model') }
161
+ : {}),
162
+ ...(options.thinking ? { thinking: options.thinking } : {}),
163
+ ...(options.promptOptimization !== undefined
164
+ ? { promptOptimization: readBooleanOption(options.promptOptimization, '--prompt-optimization') }
165
+ : {}),
166
+ ...(options.forceWeb !== undefined ? { force_web: true } : {}),
167
+ ...(options.dimensionCheck ? { dimension_check: true } : {}),
168
+ ...(readEditNormalizations(options) ? { normalizations: readEditNormalizations(options) } : {})
169
+ },
170
+ next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。'
171
+ },
172
+ null,
173
+ 2
174
+ )
175
+ );
176
+ process.exit(0);
177
  }
178
 
179
  const routingGuidance = buildEditRoutingGuidance(options);
180
  if (routingGuidance.transport === 'page_sse') {
181
+ try {
182
+ assertPageSseStreamingAllowed(options);
183
+ } catch (error) {
184
+ console.error(errorMessage(error));
185
+ process.exit(2);
186
+ }
187
  }
188
 
189
  function parseArgs(argv) {
190
+ const parsed = {
191
+ model: 'gpt-image-2',
192
+ size: 'auto',
193
+ quality: 'auto',
194
+ responseMode: 'path',
195
+ routeMode: 'auto',
196
+ streamMode: undefined,
197
+ streamingStrategy: undefined,
198
+ partialImages: undefined,
199
+ format: DEFAULT_PAGE_OUTPUT_FORMAT,
200
+ formatSpecified: false,
201
+ outputCompression: undefined,
202
+ moderation: undefined,
203
+ imageBackend: undefined,
204
+ responsesModel: undefined,
205
+ thinking: undefined,
206
+ promptOptimization: undefined,
207
+ forceWeb: undefined,
208
+ dimensionCheck: false,
209
+ sseLogPath: undefined,
210
+ timeoutMs: undefined,
211
+ baseUrl: undefined,
212
+ idempotencyKey: undefined,
213
+ imagePath: undefined,
214
+ imagePathSource: undefined,
215
+ dryRun: false,
216
+ allowBillable: false,
217
+ help: false,
218
+ promptParts: []
219
+ };
220
+ for (let index = 0; index < argv.length; index += 1) {
221
+ const arg = argv[index];
222
+ if (arg === '--dry-run') parsed.dryRun = true;
223
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
224
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
225
+ else if (arg === '--contract-check') continue;
226
+ else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
227
+ else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
228
+ else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
229
+ else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
230
+ else if (arg === '--agent') parsed.routeMode = 'agent';
231
+ else if (arg === '--page-sse') parsed.routeMode = 'page_sse';
232
+ else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(argv, (index += 1), arg);
233
+ else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg);
234
+ else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg);
235
+ else if (arg === '--format' || arg === '--output-format') {
236
+ parsed.format = readOptionValue(argv, (index += 1), arg);
237
+ parsed.formatSpecified = true;
238
+ } else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(argv, (index += 1), arg);
239
+ else if (arg === '--moderation') parsed.moderation = readOptionValue(argv, (index += 1), arg);
240
+ else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg);
241
+ else if (arg === '--responses-model' || arg === '--gpt-model')
242
+ parsed.responsesModel = readOptionValue(argv, (index += 1), arg);
243
+ else if (arg === '--thinking') parsed.thinking = readOptionValue(argv, (index += 1), arg);
244
+ else if (arg === '--prompt-optimization') parsed.promptOptimization = readOptionValue(argv, (index += 1), arg);
245
+ else if (arg === '--force-web') parsed.forceWeb = true;
246
+ else if (arg === '--dimension-check') parsed.dimensionCheck = true;
247
+ else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(argv, (index += 1), arg);
248
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
249
+ else if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
250
+ else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
251
+ else if (arg === '--image') {
252
+ if (parsed.imagePathSource === 'option') throw new Error('--image 只能设置一次。');
253
+ if (parsed.imagePath) throw new Error('--image 与位置参数 <image-path> 不能同时设置。');
254
+ parsed.imagePath = readOptionValue(argv, (index += 1), arg);
255
+ parsed.imagePathSource = 'option';
256
+ } else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
257
+ else if (!parsed.imagePath) {
258
+ parsed.imagePath = arg;
259
+ parsed.imagePathSource = 'positional';
260
+ } else parsed.promptParts.push(arg);
261
+ }
262
+ return parsed;
263
  }
264
 
265
  function authHeaders() {
266
+ if (token) return { Authorization: `Bearer ${token}` };
267
+ if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
268
+ return {};
269
  }
270
 
271
  function absoluteUrl(value) {
272
+ if (typeof value !== 'string' || !value) return undefined;
273
+ return new URL(value, `${baseUrl}/`).toString();
274
  }
275
 
276
  function buildDryRunVerificationScope() {
277
+ return {
278
+ mode: 'local_planning_only',
279
+ service_base_url: baseUrl,
280
+ service_base_url_source: baseUrlInfo.source,
281
+ interactive_confirmation_required: baseUrlInfo.interactive_confirmation_required,
282
+ remote_capabilities_verified: false,
283
+ runtime_capacity_verified: false,
284
+ auth_verified: false,
285
+ billable_request_sent: false,
286
+ note: 'Dry-run validates local request construction and routing guidance only; run --contract-check or --allow-billable to verify the remote service.'
287
+ };
288
  }
289
 
290
  function buildEditRoutingGuidance(parsed) {
291
+ if (parsed.routeMode === 'agent') {
292
+ assertNoPageOnlyEditOptions(parsed, 'Agent edit');
293
+ return {
294
+ recommended_endpoint: '/api/agent/images/edit',
295
+ transport: 'agent_json',
296
+ strength: 'default',
297
+ reason: 'Explicit --agent requests use the Agent JSON edit response contract.'
298
+ };
299
+ }
300
+ if (parsed.routeMode === 'page_sse') {
301
+ return {
302
+ recommended_endpoint: '/api/images',
303
+ transport: 'page_sse',
304
+ strength: 'default',
305
+ reason: 'Explicit --page-sse requests use the page form-data SSE endpoint.'
306
+ };
307
+ }
308
+ if (hasPageOnlyEditOptions(parsed) && isPageSseAllowed(parsed)) {
309
+ return {
310
+ recommended_endpoint: '/api/images',
311
+ transport: 'page_sse',
312
+ strength: 'default',
313
+ reason: 'GPT2Image-compatible edit options require the page form-data SSE endpoint; Agent JSON edit does not accept those fields.'
314
+ };
315
+ }
316
+ if (usesDefaultPageOutput(parsed) && isPageSseAllowed(parsed)) {
317
+ return {
318
+ recommended_endpoint: '/api/images',
319
+ transport: 'page_sse',
320
+ strength: 'default',
321
+ reason: 'Default WebP edit output uses the page form-data SSE endpoint; Agent JSON edit has a fixed output contract.'
322
+ };
323
+ }
324
+ if (readMaxImageEdge(parsed.size) > 2048 && isPageSseAllowed(parsed)) {
325
+ return {
326
+ recommended_endpoint: '/api/images',
327
+ transport: 'page_sse',
328
+ strength: 'default',
329
+ reason: 'High-resolution edit defaults to the page form-data SSE endpoint; if streaming has issues, diagnose first and explicitly fall back to Agent edit.'
330
+ };
331
+ }
332
  return {
333
+ recommended_endpoint: '/api/agent/images/edit',
334
+ transport: 'agent_json',
335
+ strength: 'default',
336
+ reason: 'Agent edit uses a fixed output contract and should be selected explicitly with --agent.'
337
  };
 
 
 
 
 
 
 
338
  }
339
 
340
  function enrichImageUrls(result) {
341
+ if (!result || !Array.isArray(result.images)) return result;
342
+ return {
343
+ ...result,
344
+ images: result.images.map((image) => ({
345
+ ...image,
346
+ ...(image.content_url ? { absolute_content_url: absoluteUrl(image.content_url) } : {}),
347
+ ...(image.metadata_url ? { absolute_metadata_url: absoluteUrl(image.metadata_url) } : {})
348
+ }))
349
+ };
350
  }
351
 
352
  async function readCapabilities() {
353
+ let response;
354
+ try {
355
+ response = await fetchWithTimeout(`${baseUrl}/api/agent/capabilities`, {
356
+ headers: authHeaders()
357
+ });
358
+ } catch (error) {
359
+ const message = errorMessage(error);
360
+ throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
361
+ }
362
+ if (!response.ok) {
363
+ const body = await response.text();
364
+ throw new Error(`capabilities 请求失败,状态码 ${response.status}:${body}`);
365
+ }
366
+ return response.json();
367
  }
368
 
369
  function assertPageSseReadyForEdit(capabilities) {
370
+ assertPageSseReady({
371
+ capabilities,
372
+ passwordHash,
373
+ idempotencyKey
374
+ });
375
  }
376
 
377
  function shouldRetry(result) {
378
+ return Boolean(result?.error?.retryable);
379
  }
380
 
381
  function validateUpstreamStreamingOptions(parsed) {
382
+ if (parsed.streamMode && !STREAM_MODES.has(parsed.streamMode)) {
383
+ throw new Error('--stream-mode 必须是 auto、stream 或 non_stream。');
384
+ }
385
+ if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) {
386
+ throw new Error(
387
+ '--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'
388
+ );
389
+ }
390
+ if (parsed.routeMode === 'page_sse') {
391
+ assertPageSseStreamingAllowed(parsed);
392
+ }
393
+ if (parsed.dimensionCheck && !parseImageSizeValue(parsed.size)) {
394
+ throw new Error('--dimension-check 需要 --size WIDTHxHEIGHT。');
395
+ }
396
+ if (hasPageOnlyEditOptions(parsed) && !isPageSseAllowed(parsed)) {
397
+ throw new Error('图生图高级参数需要页面 SSE,不能同时设置 stream_mode=non_stream streaming_strategy=off。');
398
+ }
399
+ if (parsed.routeMode !== 'agent' && usesDefaultPageOutput(parsed) && !isPageSseAllowed(parsed)) {
400
+ throw new Error('默认 WebP 图生图输出需要页面 SSE;若要使用 Agent JSON 固定输出,请添加 --agent。');
401
+ }
402
+ if (parsed.routeMode === 'agent') {
403
+ assertNoPageOnlyEditOptions(parsed, 'Agent edit');
404
+ }
405
+ if (parsed.formatSpecified && !OUTPUT_FORMATS.has(readOutputFormat(parsed))) {
406
+ throw new Error('--format 必须是 png、jpeg 或 webp。');
407
+ }
408
+ if (parsed.outputCompression !== undefined) readOutputCompression(parsed);
409
+ if (parsed.moderation && !MODERATIONS.has(parsed.moderation)) {
410
+ throw new Error('--moderation 必须是 lowauto。');
411
+ }
412
+ if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
413
+ throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
414
+ }
415
+ if (parsed.responsesModel !== undefined) readNonEmptyString(parsed.responsesModel, '--responses-model');
416
+ if (parsed.thinking && !THINKING_VALUES.has(parsed.thinking)) {
417
+ throw new Error('--thinking 必须是 minimal、none、low、medium、high xhigh。');
418
+ }
419
+ if (parsed.promptOptimization !== undefined) readBooleanOption(parsed.promptOptimization, '--prompt-optimization');
420
+ if (parsed.partialImages !== undefined) readPartialImages(parsed.partialImages, '--partial-images');
421
  }
422
 
423
  function hasPageOnlyEditOptions(parsed) {
424
+ return Boolean(
425
+ parsed.formatSpecified ||
426
+ parsed.outputCompression !== undefined ||
427
+ parsed.moderation ||
428
+ parsed.imageBackend ||
429
+ parsed.responsesModel ||
430
+ parsed.thinking ||
431
+ parsed.promptOptimization !== undefined ||
432
+ parsed.forceWeb !== undefined
433
+ );
434
  }
435
 
436
  function assertNoPageOnlyEditOptions(parsed, context) {
437
+ if (!hasPageOnlyEditOptions(parsed)) return;
438
+ throw new Error(`${context} 不接受图生图高级页面字段;请去掉这些字段或使用 --page-sse。`);
439
  }
440
 
441
  function readOutputFormat(parsed) {
442
+ return parsed.format ? normalizeOutputFormat(parsed.format) : DEFAULT_PAGE_OUTPUT_FORMAT;
443
  }
444
 
445
  function usesPageOutputOptions(parsed, routingGuidance) {
446
+ return routingGuidance.transport === 'page_sse' || parsed.formatSpecified || parsed.outputCompression !== undefined;
447
  }
448
 
449
  function usesDefaultPageOutput(parsed) {
450
+ return (
451
+ !parsed.formatSpecified &&
452
+ parsed.outputCompression === undefined &&
453
+ readOutputFormat(parsed) === DEFAULT_PAGE_OUTPUT_FORMAT
454
+ );
455
  }
456
 
457
  function readOutputCompression(parsed) {
458
+ const outputFormat = readOutputFormat(parsed);
459
+ if (outputFormat === 'png') return undefined;
460
+ const value =
461
+ parsed.outputCompression === undefined
462
+ ? String(DEFAULT_PAGE_OUTPUT_COMPRESSION)
463
+ : String(parsed.outputCompression);
464
+ if (!/^\d+$/.test(value)) throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
465
+ const parsedValue = Number(value);
466
+ if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 100) {
467
+ throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
468
+ }
469
+ return parsedValue;
470
  }
471
 
472
  function readEditNormalizations(parsed) {
473
+ if (parsed.outputCompression === undefined || readOutputFormat(parsed) !== 'png') return undefined;
474
+ return { output_compression_ignored_for_png: true };
475
  }
476
 
477
  function readBooleanOption(value, name) {
478
+ if (value === true || value === 'true') return true;
479
+ if (value === false || value === 'false') return false;
480
+ throw new Error(`${name} 必须是 true 或 false。`);
481
  }
482
 
483
  function readNonEmptyString(value, name) {
484
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} 必须是非空字符串。`);
485
+ return value.trim();
486
+ }
487
+
488
+ async function assertDimensionsIfRequested(response) {
489
+ if (!options.dimensionCheck) return response;
490
+ return assertImageDimensions({
491
+ response,
492
+ expected: parseExpectedDimensions(options.size),
493
+ baseUrl,
494
+ authHeaders,
495
+ timeoutMs,
496
+ nextStep: EDIT_DIMENSION_CHECK_NEXT_STEP,
497
+ readUrlFields: DIMENSION_CHECK_URL_FIELDS
498
+ });
499
+ }
500
+
501
+ async function buildSuccessOutput(result, routing, { dimensionChecked = false } = {}) {
502
+ const checkedResult = dimensionChecked ? result : await assertDimensionsIfRequested(enrichImageUrls(result));
503
+ return attachSummary(
504
+ routing ? { ...checkedResult, routing } : checkedResult,
505
+ buildSuccessSummary({
506
+ result: checkedResult,
507
+ routing,
508
+ timing: completeScriptTiming(scriptTiming),
509
+ idempotencyKey,
510
+ billable: true
511
+ })
512
+ );
513
+ }
514
+
515
+ function buildFailureOutput(output, routing) {
516
+ return attachSummary(
517
+ output,
518
+ buildFailureSummary({
519
+ errorBody: output,
520
+ routing,
521
+ timing: completeScriptTiming(scriptTiming),
522
+ idempotencyKey,
523
+ billable: output?.billable !== false,
524
+ nextAction: output?.next_step
525
+ })
526
+ );
527
+ }
528
+
529
+ function buildDimensionCheckFailureOutput(error, routing) {
530
+ return buildFailureOutput(buildDimensionCheckFailureBody(error, routing), routing);
531
  }
532
 
533
  function isPageSseAllowed(parsed) {
534
+ return parsed.streamMode !== 'non_stream' && parsed.streamingStrategy !== 'off';
535
  }
536
 
537
  async function fetchWithTimeout(url, init) {
538
+ const controller = new AbortController();
539
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
540
+ try {
541
+ return await fetch(url, { ...init, signal: controller.signal });
542
+ } finally {
543
+ clearTimeout(timeout);
544
+ }
545
  }
546
 
547
  function printUsage() {
548
+ console.error(
549
+ '用法:edit-image.mjs [options] <image-path> <prompt> 或 edit-image.mjs --image <path> [options] <prompt>'
550
+ );
551
+ console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
552
+ console.error(
553
+ '常用参数:--image --model --size --quality --response-mode --format --output-compression --moderation --image-backend --responses-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --dimension-check --sse-log --timeout-ms --base-url --idempotency-key --page-sse --agent --dry-run --allow-billable'
554
+ );
555
+ console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
556
  }
557
 
558
  let capabilities;
559
  try {
560
+ capabilities = await readCapabilities();
561
+ if (options.timeoutMs === undefined) {
562
+ timeoutMs = readCapabilitiesImageTransportTimeoutMs(capabilities, timeoutMs);
563
+ }
564
  } catch (error) {
565
+ console.error(errorMessage(error));
566
+ process.exit(1);
567
  }
568
 
569
  try {
570
+ validateAgentEditRequestAgainstCapabilities(
571
+ {
572
+ n: 1,
573
+ partial_images:
574
+ options.partialImages !== undefined
575
+ ? readPartialImages(options.partialImages, '--partial-images')
576
+ : capabilities?.defaults?.partial_images,
577
+ imageCount: 1,
578
+ image_backend: options.imageBackend
579
+ },
580
+ capabilities
581
+ );
582
  } catch (error) {
583
+ console.error(errorMessage(error));
584
+ process.exit(2);
585
  }
586
 
587
  if (contractCheck) {
588
+ const response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
589
+ method: 'POST',
590
+ headers: {
591
+ 'Idempotency-Key': idempotencyKey,
592
+ 'Content-Type': 'application/json',
593
+ ...authHeaders()
594
+ },
595
+ body: JSON.stringify({ prompt: 'contract check' })
596
+ });
597
+ const result = await response.json();
598
+ if (response.status === 415 && result?.error?.code === 'validation_error') {
599
+ console.log(
600
+ JSON.stringify(
601
+ { ok: true, billable: false, status: response.status, error_code: result.error.code },
602
+ null,
603
+ 2
604
+ )
605
+ );
606
+ process.exit(0);
607
+ }
608
+ console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
609
+ process.exit(1);
610
  }
611
 
612
  try {
613
+ const stats = fs.statSync(imagePath);
614
+ if (!stats.isFile()) {
615
+ console.error(`图片路径不是文件:${imagePath}`);
616
+ process.exit(2);
617
+ }
618
  } catch (error) {
619
+ const message = errorMessage(error);
620
+ console.error(`无法读取图片文件:${imagePath}。${message}`);
621
+ process.exit(2);
622
  }
623
 
624
  const imageBuffer = fs.readFileSync(imagePath);
625
  const imageType = mimeTypeForPath(imagePath);
626
 
627
  function buildFormData() {
628
+ const formData = new FormData();
629
+ formData.append('prompt', prompt);
630
+ formData.append('model', options.model);
631
+ formData.append('size', options.size);
632
+ formData.append('quality', options.quality);
633
+ formData.append('response_mode', options.responseMode);
634
+ if (options.streamMode) formData.append('stream_mode', options.streamMode);
635
+ if (options.streamingStrategy) formData.append('streaming_strategy', options.streamingStrategy);
636
+ if (options.partialImages !== undefined) {
637
+ formData.append('partial_images', String(readPartialImages(options.partialImages, '--partial-images')));
638
+ } else if (capabilities?.defaults?.partial_images !== undefined) {
639
+ formData.append(
640
+ 'partial_images',
641
+ String(readPartialImages(capabilities.defaults.partial_images, 'capabilities.defaults.partial_images'))
642
+ );
643
+ }
644
+ formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
645
+ return formData;
646
  }
647
 
648
  function buildPageSseFormData() {
649
+ const formData = new FormData();
650
+ formData.append('mode', 'edit');
651
+ formData.append('prompt', prompt);
652
+ formData.append('model', options.model);
653
+ formData.append('size', options.size);
654
+ formData.append('quality', options.quality);
655
+ formData.append('response_mode', options.responseMode);
656
+ formData.append('clientRequestId', idempotencyKey);
657
+ formData.append('stream', 'true');
658
+ formData.append('output_format', readOutputFormat(options));
659
+ if (readOutputCompression(options) !== undefined) {
660
+ formData.append('output_compression', String(readOutputCompression(options)));
661
+ }
662
+ if (options.moderation) formData.append('moderation', options.moderation);
663
+ if (options.imageBackend) formData.append('image_backend', normalizeImageBackendForPage(options.imageBackend));
664
+ if (options.responsesModel)
665
+ formData.append('responsesModel', readNonEmptyString(options.responsesModel, '--responses-model'));
666
+ if (options.thinking) formData.append('thinking', options.thinking);
667
+ if (options.promptOptimization !== undefined) {
668
+ formData.append(
669
+ 'promptOptimization',
670
+ String(readBooleanOption(options.promptOptimization, '--prompt-optimization'))
671
+ );
672
+ }
673
+ if (options.forceWeb !== undefined) formData.append('force_web', 'true');
674
+ if (options.streamMode) formData.append('stream_mode', options.streamMode);
675
+ if (options.streamingStrategy) formData.append('image_streaming_strategy', options.streamingStrategy);
676
+ if (options.partialImages !== undefined) {
677
+ formData.append('partial_images', String(readPartialImages(options.partialImages, '--partial-images')));
678
+ } else if (capabilities?.defaults?.partial_images !== undefined) {
679
+ formData.append(
680
+ 'partial_images',
681
+ String(readPartialImages(capabilities.defaults.partial_images, 'capabilities.defaults.partial_images'))
682
+ );
683
+ }
684
+ if (passwordHash) formData.append('passwordHash', passwordHash);
685
+ formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
686
+ return formData;
687
  }
688
 
689
  async function runPageSseEdit() {
690
+ assertPageSseReadyForEdit(capabilities);
691
+ const result = await postPageSse({
692
+ url: `${baseUrl}${PAGE_SSE_ENDPOINT}`,
693
+ formData: buildPageSseFormData(),
694
+ timeoutMs,
695
+ sseLogPath: options.sseLogPath,
696
+ errorMessage
697
+ });
698
+ const routing = buildPageSseEditRouting();
699
+ const checkedResult = await assertDimensionsIfRequested(formatPageSseDimensionCheckInput(result));
700
+ console.log(
701
+ JSON.stringify(
702
+ await buildSuccessOutput(
703
+ formatPageSseOutput({
704
+ result: checkedResult,
705
+ baseUrl,
706
+ responseMode: options.responseMode,
707
+ defaultOutputFormat: DEFAULT_PAGE_OUTPUT_FORMAT
708
+ }),
709
+ routing,
710
+ { dimensionChecked: true }
711
+ ),
712
+ null,
713
+ 2
714
+ )
715
+ );
716
+ }
717
+
718
+ function buildPageSseEditRouting() {
719
+ return {
720
+ transport: 'page_sse',
721
+ endpoint: PAGE_SSE_ENDPOINT,
722
+ fallback_endpoint: '/api/agent/images/edit',
723
+ fallback_mode: 'manual_after_diagnosis'
724
+ };
725
+ }
726
+
727
+ function formatPageSseDimensionCheckInput(result) {
728
+ return formatPageSseOutput({
729
  result,
730
+ baseUrl,
731
+ responseMode: 'both',
732
+ defaultOutputFormat: DEFAULT_PAGE_OUTPUT_FORMAT
733
+ });
 
 
 
 
 
 
 
 
734
  }
735
 
736
  function mimeTypeForPath(filePath) {
737
+ const ext = path.extname(filePath).toLowerCase();
738
+ if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
739
+ if (ext === '.webp') return 'image/webp';
740
+ return 'image/png';
741
  }
742
 
743
  let lastResult;
744
  let lastRetryAfter = null;
745
 
746
  if (routingGuidance.transport === 'page_sse') {
747
+ try {
748
+ await runPageSseEdit();
749
+ process.exit(0);
750
+ } catch (error) {
751
+ if (isDimensionCheckError(error)) {
752
+ console.error(JSON.stringify(buildDimensionCheckFailureOutput(error, buildPageSseEditRouting()), null, 2));
753
+ process.exit(1);
754
+ }
755
+ const failureOutput = buildPageSseFailureOutput({
756
+ error,
757
+ fallbackEndpoint: '/api/agent/images/edit',
758
+ errorMessage
759
+ });
760
+ console.error(JSON.stringify(buildFailureOutput(failureOutput, buildPageSseEditRouting()), null, 2));
761
+ process.exit(1);
762
+ }
 
 
 
 
 
 
 
 
 
 
 
763
  }
764
 
765
  async function buildAgentFailureOutput(output, routing) {
766
+ const failure = buildFailureOutput(output, routing);
767
+ const enriched = await enrichFailureWithAgentDiagnostics({
768
+ baseUrl,
769
+ authHeaders,
770
+ idempotencyKey,
771
+ failureOutput: failure,
772
+ summary: failure.summary,
773
+ timeoutMs
774
+ });
775
+ return attachSummary(enriched.failureOutput, enriched.summary);
 
 
 
 
 
 
776
  }
777
 
778
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
779
+ let response;
780
+ let result;
781
+ try {
782
+ response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
783
+ method: 'POST',
784
+ headers: {
785
+ 'Idempotency-Key': idempotencyKey,
786
+ ...authHeaders()
787
+ },
788
+ body: buildFormData()
789
+ });
790
+ result = await response.json();
791
+ } catch (error) {
792
+ const message = errorMessage(error);
793
+ result = { error: { code: 'network_error', message, retryable: true } };
794
+ lastResult = result;
795
+ lastRetryAfter = 1;
796
+ if (attempt === maxAttempts) break;
797
+ await sleep(lastRetryAfter);
798
+ continue;
799
+ }
800
+ if (response.ok) {
801
+ try {
802
+ console.log(
803
+ JSON.stringify(
804
+ await buildSuccessOutput(result, { transport: 'agent_json', endpoint: '/api/agent/images/edit' }),
805
+ null,
806
+ 2
807
+ )
808
+ );
809
+ process.exit(0);
810
+ } catch (error) {
811
+ if (!isDimensionCheckError(error)) throw error;
812
+ console.error(
813
+ JSON.stringify(
814
+ buildDimensionCheckFailureOutput(error, {
815
+ transport: 'agent_json',
816
+ endpoint: '/api/agent/images/edit'
817
+ }),
818
+ null,
819
+ 2
820
+ )
821
+ );
822
+ process.exit(1);
823
+ }
824
+ }
825
 
826
+ const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
827
+ lastResult = result;
828
+ lastRetryAfter = retryAfter;
829
+ if (!shouldRetry(result) || attempt === maxAttempts) break;
830
+ await sleep(retryAfter);
831
  }
832
 
833
  const failureOutput = { ...lastResult, retry_after: lastRetryAfter };
834
  console.error(
835
+ JSON.stringify(
836
+ await buildAgentFailureOutput(failureOutput, { transport: 'agent_json', endpoint: '/api/agent/images/edit' }),
837
+ null,
838
+ 2
839
+ )
840
  );
841
  process.exit(1);
skills/gpt-image-playground-agent/scripts/generate-image.mjs CHANGED
@@ -1,10 +1,25 @@
1
  #!/usr/bin/env node
2
  import { AGENT_ENDPOINTS, buildAgentArtifactSharePath, buildAgentJobResultPath } from './lib/agent-api-paths.mjs';
3
  import { enrichFailureWithAgentDiagnostics } from './lib/agent-diagnostics-summary.mjs';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  import {
5
  errorMessage,
6
  assertValidImageSizeForModel,
7
  normalizeOutputFormat,
 
8
  parseRetryAfterValue,
9
  readCapabilitiesImageTransportTimeoutMs,
10
  readConfiguredPositiveInteger,
@@ -17,13 +32,6 @@ import {
17
  sleep,
18
  validateAgentGenerateRequestAgainstCapabilities
19
  } from './lib/script-utils.mjs';
20
- import {
21
- attachSummary,
22
- buildFailureSummary,
23
- buildSuccessSummary,
24
- completeScriptTiming,
25
- startScriptTiming
26
- } from './lib/script-summary.mjs';
27
  import crypto from 'node:crypto';
28
  import fs from 'node:fs';
29
  import path from 'node:path';
@@ -45,6 +53,7 @@ const DEFAULT_OUTPUT_FORMAT = 'webp';
45
  const DEFAULT_OUTPUT_COMPRESSION = 100;
46
  const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
47
  const PAGE_SSE_ENDPOINT = '/api/images';
 
48
  const SERVER_ORCHESTRATED_TRANSPORT = 'server_orchestrated';
49
  const MIN_SHARE_ACCESS_CODE_LENGTH = 8;
50
  const MAX_SHARE_ACCESS_CODE_LENGTH = 128;
@@ -68,6 +77,7 @@ const GENERATE_PRESETS = {
68
  '2'
69
  ]
70
  };
 
71
  loadPrivateAgentEnvFile();
72
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
73
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
@@ -209,22 +219,32 @@ try {
209
  }
210
  });
211
  } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
 
 
 
 
 
212
  try {
213
  const result = await runPageSseRequest();
214
- console.log(
215
- JSON.stringify(
216
- await buildSuccessOutput(formatPageSseOutput(result), {
217
- ...buildPageSseSummaryRouting(),
218
- fallback_endpoint: AGENT_ENDPOINTS.create_image_request,
219
- fallback_mode: 'manual_after_diagnosis'
220
- }, completeScriptTiming(scriptTiming)),
221
- null,
222
- 2
223
- )
224
  );
225
- process.exit(0);
226
  } catch (error) {
227
- console.error(JSON.stringify(buildPageSseFailureOutput(error, completeScriptTiming(scriptTiming)), null, 2));
 
 
 
 
 
 
228
  process.exit(1);
229
  }
230
  } else {
@@ -256,6 +276,7 @@ function parseArgs(argv) {
256
  forceWeb: undefined,
257
  share: false,
258
  shareExpiresMinutes: undefined,
 
259
  streamMode: undefined,
260
  streamingStrategy: undefined,
261
  partialImages: undefined,
@@ -285,18 +306,25 @@ function parseArgs(argv) {
285
  else if (arg === '--size') parsed.size = readOptionValue(expandedArgv, (index += 1), arg);
286
  else if (arg === '--quality') parsed.quality = readOptionValue(expandedArgv, (index += 1), arg);
287
  else if (arg === '--n') parsed.n = readOptionValue(expandedArgv, (index += 1), arg);
288
- else if (arg === '--format' || arg === '--output-format') parsed.format = readOptionValue(expandedArgv, (index += 1), arg);
289
- else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(expandedArgv, (index += 1), arg);
 
 
290
  else if (arg === '--response-mode') parsed.responseMode = readOptionValue(expandedArgv, (index += 1), arg);
291
  else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(expandedArgv, (index += 1), arg);
292
- else if (arg === '--responses-model' || arg === '--gpt-model') parsed.responsesModel = readOptionValue(expandedArgv, (index += 1), arg);
 
293
  else if (arg === '--thinking') parsed.thinking = readOptionValue(expandedArgv, (index += 1), arg);
294
- else if (arg === '--prompt-optimization') parsed.promptOptimization = readOptionValue(expandedArgv, (index += 1), arg);
 
295
  else if (arg === '--force-web') parsed.forceWeb = true;
296
  else if (arg === '--share') parsed.share = true;
297
- else if (arg === '--share-expires-minutes') parsed.shareExpiresMinutes = readOptionValue(expandedArgv, (index += 1), arg);
 
 
298
  else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(expandedArgv, (index += 1), arg);
299
- else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(expandedArgv, (index += 1), arg);
 
300
  else if (arg === '--partial-images') parsed.partialImages = readOptionValue(expandedArgv, (index += 1), arg);
301
  else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(expandedArgv, (index += 1), arg);
302
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(expandedArgv, (index += 1), arg);
@@ -347,7 +375,9 @@ function buildRequestBody(promptValue, parsed) {
347
  size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'),
348
  quality: parsed.quality,
349
  output_format: normalizeOutputFormat(parsed.format),
350
- ...(readOutputCompression(parsed) !== undefined ? { output_compression: readOutputCompression(parsed) } : {}),
 
 
351
  response_mode: parsed.responseMode
352
  },
353
  parsed
@@ -362,7 +392,9 @@ function buildDryRunRequestBody(parsed) {
362
  size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'),
363
  quality: parsed.quality,
364
  output_format: normalizeOutputFormat(parsed.format),
365
- ...(readOutputCompression(parsed) !== undefined ? { output_compression: readOutputCompression(parsed) } : {}),
 
 
366
  response_mode: parsed.responseMode
367
  },
368
  parsed
@@ -378,7 +410,9 @@ function addUpstreamStrategyFields(body, parsed) {
378
  return {
379
  ...body,
380
  ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}),
381
- ...(parsed.responsesModel ? { responsesModel: readNonEmptyString(parsed.responsesModel, '--responses-model') } : {}),
 
 
382
  ...(parsed.thinking ? { thinking: parsed.thinking } : {}),
383
  ...(parsed.promptOptimization !== undefined
384
  ? { promptOptimization: readBooleanOption(parsed.promptOptimization, '--prompt-optimization') }
@@ -396,6 +430,9 @@ function validateUpstreamStrategyOptions(parsed) {
396
  if (!RESPONSE_MODES.has(parsed.responseMode)) {
397
  throw new Error('--response-mode 必须是 path、base64 或 both。');
398
  }
 
 
 
399
  if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
400
  throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
401
  }
@@ -446,7 +483,8 @@ function validateResponsesModelBackend(parsed) {
446
  function readOutputCompression(parsed) {
447
  const outputFormat = normalizeOutputFormat(parsed.format);
448
  if (outputFormat === 'png') return undefined;
449
- const value = parsed.outputCompression === undefined ? String(DEFAULT_OUTPUT_COMPRESSION) : String(parsed.outputCompression);
 
450
  if (!/^\d+$/.test(value)) throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
451
  const parsedValue = Number(value);
452
  if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 100) {
@@ -670,14 +708,11 @@ async function runGenerateRequest(options = {}) {
670
  });
671
 
672
  if (response.ok) {
673
- console.log(
674
- JSON.stringify(
675
- await buildSuccessOutput(enrichImageUrls(result), options.routing, completeScriptTiming(scriptTiming)),
676
- null,
677
- 2
678
- )
679
  );
680
- process.exit(0);
681
  }
682
 
683
  const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
@@ -825,23 +860,14 @@ function assertPageSseClientRequestIdLength(clientRequestId) {
825
  }
826
  }
827
 
828
- function formatPageSseOutput(result) {
829
- if (!result || !Array.isArray(result.images)) return result;
830
- return {
831
- ...result,
832
- images: result.images.map((image) => formatPageSseImage(image))
833
- };
834
- }
835
-
836
- function formatPageSseImage(image) {
837
- const output = { ...image };
838
- if (output.path) {
839
- output.absolute_path = absoluteUrl(output.path);
840
- if (requestBody.response_mode === 'path') {
841
- delete output.b64_json;
842
- }
843
- }
844
- return output;
845
  }
846
 
847
  function createPageSseState() {
@@ -924,14 +950,22 @@ async function collectPageSseResult(response, signal) {
924
  elapsed_ms: completeScriptTiming(scriptTiming).elapsed_ms,
925
  final_image_count: state.completedImages.length
926
  });
927
- return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost, sse_diagnostics: buildPageSseDiagnostics(state) };
 
 
 
 
 
928
  }
929
 
930
  function appendPageSseLog(rawEvent) {
931
  if (!options.sseLogPath || !rawEvent.trim()) return;
932
  try {
933
  fs.mkdirSync(path.dirname(options.sseLogPath), { recursive: true });
934
- fs.appendFileSync(options.sseLogPath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`);
 
 
 
935
  } catch (error) {
936
  console.warn(`SSE log write failed: ${errorMessage(error)}`);
937
  }
@@ -941,7 +975,10 @@ function appendPageSseTrace(event, details) {
941
  if (!options.sseLogPath) return;
942
  try {
943
  fs.mkdirSync(path.dirname(options.sseLogPath), { recursive: true });
944
- fs.appendFileSync(options.sseLogPath, `${JSON.stringify({ at: new Date().toISOString(), event, ...details })}\n`);
 
 
 
945
  } catch (error) {
946
  console.warn(`SSE log write failed: ${errorMessage(error)}`);
947
  }
@@ -1054,11 +1091,12 @@ function parsePageSseEvent(rawEvent) {
1054
  }
1055
  }
1056
 
1057
- async function buildSuccessOutput(result, routing, timing) {
 
1058
  const output = attachSummary(
1059
- routing ? { ...result, routing } : result,
1060
  buildSuccessSummary({
1061
- result,
1062
  routing,
1063
  timing,
1064
  idempotencyKey,
@@ -1069,7 +1107,32 @@ async function buildSuccessOutput(result, routing, timing) {
1069
  return output;
1070
  }
1071
  const shareSummary = await maybeCreateShares(output);
1072
- return shareSummary ? attachSummary({ ...output, shares: shareSummary.share_results }, { ...output.summary, ...shareSummary }) : output;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1073
  }
1074
 
1075
  async function maybeCreateShares(output) {
@@ -1092,7 +1155,11 @@ async function maybeCreateShares(output) {
1092
  async function createShareForArtifact(artifactId) {
1093
  const body = {};
1094
  if (options.shareExpiresMinutes !== undefined) {
1095
- body.expires_in_minutes = readConfiguredPositiveInteger(options.shareExpiresMinutes, '--share-expires-minutes', 1);
 
 
 
 
1096
  }
1097
  if (process.env.GPT_IMAGE_SHARE_ACCESS_CODE !== undefined) {
1098
  body.access_code = readShareAccessCodeFromEnv();
@@ -1177,14 +1244,17 @@ function buildPageSseScriptFailure(error, diagnostics, timing) {
1177
  routing: buildPageSseRouting('manual_after_diagnosis'),
1178
  next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到其他端点。'
1179
  };
1180
- return attachSummary(output, buildFailureSummary({
1181
- errorBody: output,
1182
- routing: output.routing,
1183
- timing,
1184
- idempotencyKey,
1185
- billable: false,
1186
- nextAction: 'fix_capability_or_auth'
1187
- }));
 
 
 
1188
  }
1189
 
1190
  function buildPageSseRequestRejectedFailure(error, diagnostics, timing) {
@@ -1200,14 +1270,17 @@ function buildPageSseRequestRejectedFailure(error, diagnostics, timing) {
1200
  routing: buildPageSseRouting('fix_request_before_retry'),
1201
  next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
1202
  };
1203
- return attachSummary(output, buildFailureSummary({
1204
- errorBody: output,
1205
- routing: output.routing,
1206
- timing,
1207
- idempotencyKey,
1208
- billable: false,
1209
- nextAction: 'fix_request_before_retry'
1210
- }));
 
 
 
1211
  }
1212
 
1213
  function buildBillablePageSseFailure(error, diagnostics, timing) {
@@ -1224,14 +1297,17 @@ function buildBillablePageSseFailure(error, diagnostics, timing) {
1224
  next_step:
1225
  '先用 diagnose-request 诊断页面流式失败原因,再用新的 Idempotency-Key 显式选择服务端编排入口或其他诊断路径。'
1226
  };
1227
- return attachSummary(output, buildFailureSummary({
1228
- errorBody: output,
1229
- routing: output.routing,
1230
- timing,
1231
- idempotencyKey,
1232
- billable: true,
1233
- nextAction: 'diagnose_then_new_idempotency_key'
1234
- }));
 
 
 
1235
  }
1236
 
1237
  function buildFailureOutput(output, routing) {
@@ -1242,11 +1318,16 @@ function buildFailureOutput(output, routing) {
1242
  routing,
1243
  timing: completeScriptTiming(scriptTiming),
1244
  idempotencyKey,
1245
- billable: output?.billable !== false
 
1246
  })
1247
  );
1248
  }
1249
 
 
 
 
 
1250
  async function buildAgentFailureOutput(output, routing) {
1251
  const failure = buildFailureOutput(output, routing);
1252
  const enriched = await enrichFailureWithAgentDiagnostics({
@@ -1308,14 +1389,7 @@ async function runGenerateJob(options = {}) {
1308
 
1309
  if (response.ok) {
1310
  const jobResult = await pollJobResult(result?.job);
1311
- console.log(
1312
- JSON.stringify(
1313
- await buildSuccessOutput(enrichImageUrls(jobResult), routing, completeScriptTiming(scriptTiming)),
1314
- null,
1315
- 2
1316
- )
1317
- );
1318
- process.exit(0);
1319
  }
1320
 
1321
  const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
@@ -1325,16 +1399,7 @@ async function runGenerateJob(options = {}) {
1325
  await sleep(retryAfter);
1326
  }
1327
 
1328
- console.error(
1329
- JSON.stringify(
1330
- buildFailureOutput(
1331
- { ...lastResult, retry_after: lastRetryAfter },
1332
- routing
1333
- ),
1334
- null,
1335
- 2
1336
- )
1337
- );
1338
  process.exit(1);
1339
  }
1340
 
@@ -1419,7 +1484,9 @@ async function checkContractEndpoint(endpoint) {
1419
  timeoutMs
1420
  });
1421
  if (response.status !== 400 || result?.error?.code !== 'idempotency_key_required') {
1422
- console.error(JSON.stringify({ ok: false, billable: false, endpoint, status: response.status, result }, null, 2));
 
 
1423
  process.exit(1);
1424
  }
1425
  return { endpoint, status: response.status, error_code: result.error.code };
@@ -1444,7 +1511,13 @@ async function checkPageSseContract() {
1444
  timeoutMs
1445
  });
1446
  if (response.status !== 400 || !text.includes('clientRequestId')) {
1447
- console.error(JSON.stringify({ ok: false, billable: false, endpoint: PAGE_SSE_ENDPOINT, status: response.status, text }, null, 2));
 
 
 
 
 
 
1448
  process.exit(1);
1449
  }
1450
  return {
@@ -1650,7 +1723,7 @@ function printUsage() {
1650
  console.error('用法:generate-image.mjs [options] <prompt>');
1651
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
1652
  console.error(
1653
- '常用参数:--model --size --quality --n --format --output-compression --response-mode --image-backend --responses-model --gpt-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --share --share-expires-minutes --sse-log --timeout-ms --base-url --prompt-file --idempotency-key --check-remote --page-sse --agent --job --no-job(兼容别名)'
1654
  );
1655
  console.error(
1656
  '契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'
 
1
  #!/usr/bin/env node
2
  import { AGENT_ENDPOINTS, buildAgentArtifactSharePath, buildAgentJobResultPath } from './lib/agent-api-paths.mjs';
3
  import { enrichFailureWithAgentDiagnostics } from './lib/agent-diagnostics-summary.mjs';
4
+ import {
5
+ assertImageDimensions,
6
+ buildDimensionCheckFailureBody,
7
+ isDimensionCheckError,
8
+ parseExpectedDimensions
9
+ } from './lib/dimension-check.mjs';
10
+ import { formatPageSseOutput as formatSharedPageSseOutput } from './lib/page-sse-client.mjs';
11
+ import {
12
+ attachSummary,
13
+ buildFailureSummary,
14
+ buildSuccessSummary,
15
+ completeScriptTiming,
16
+ startScriptTiming
17
+ } from './lib/script-summary.mjs';
18
  import {
19
  errorMessage,
20
  assertValidImageSizeForModel,
21
  normalizeOutputFormat,
22
+ parseImageSizeValue,
23
  parseRetryAfterValue,
24
  readCapabilitiesImageTransportTimeoutMs,
25
  readConfiguredPositiveInteger,
 
32
  sleep,
33
  validateAgentGenerateRequestAgainstCapabilities
34
  } from './lib/script-utils.mjs';
 
 
 
 
 
 
 
35
  import crypto from 'node:crypto';
36
  import fs from 'node:fs';
37
  import path from 'node:path';
 
53
  const DEFAULT_OUTPUT_COMPRESSION = 100;
54
  const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
55
  const PAGE_SSE_ENDPOINT = '/api/images';
56
+ const DIMENSION_CHECK_URL_FIELDS = ['absolute_content_url', 'content_url', 'absolute_path', 'path'];
57
  const SERVER_ORCHESTRATED_TRANSPORT = 'server_orchestrated';
58
  const MIN_SHARE_ACCESS_CODE_LENGTH = 8;
59
  const MAX_SHARE_ACCESS_CODE_LENGTH = 128;
 
77
  '2'
78
  ]
79
  };
80
+
81
  loadPrivateAgentEnvFile();
82
  const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
83
  const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
 
219
  }
220
  });
221
  } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
222
+ const routing = {
223
+ ...buildPageSseSummaryRouting(),
224
+ fallback_endpoint: AGENT_ENDPOINTS.create_image_request,
225
+ fallback_mode: 'manual_after_diagnosis'
226
+ };
227
  try {
228
  const result = await runPageSseRequest();
229
+ const checkedResult = await assertDimensionsIfRequested(
230
+ formatPageSseOutput(result, { preserveBase64: true })
231
+ );
232
+ await writeSuccessOutputAndExit(
233
+ formatPageSseOutput(checkedResult),
234
+ routing,
235
+ completeScriptTiming(scriptTiming),
236
+ {
237
+ dimensionChecked: true
238
+ }
239
  );
 
240
  } catch (error) {
241
+ if (isDimensionCheckError(error)) {
242
+ console.error(JSON.stringify(buildDimensionCheckFailureOutput(error, routing), null, 2));
243
+ process.exit(1);
244
+ }
245
+ console.error(
246
+ JSON.stringify(buildPageSseFailureOutput(error, completeScriptTiming(scriptTiming)), null, 2)
247
+ );
248
  process.exit(1);
249
  }
250
  } else {
 
276
  forceWeb: undefined,
277
  share: false,
278
  shareExpiresMinutes: undefined,
279
+ dimensionCheck: false,
280
  streamMode: undefined,
281
  streamingStrategy: undefined,
282
  partialImages: undefined,
 
306
  else if (arg === '--size') parsed.size = readOptionValue(expandedArgv, (index += 1), arg);
307
  else if (arg === '--quality') parsed.quality = readOptionValue(expandedArgv, (index += 1), arg);
308
  else if (arg === '--n') parsed.n = readOptionValue(expandedArgv, (index += 1), arg);
309
+ else if (arg === '--format' || arg === '--output-format')
310
+ parsed.format = readOptionValue(expandedArgv, (index += 1), arg);
311
+ else if (arg === '--output-compression')
312
+ parsed.outputCompression = readOptionValue(expandedArgv, (index += 1), arg);
313
  else if (arg === '--response-mode') parsed.responseMode = readOptionValue(expandedArgv, (index += 1), arg);
314
  else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(expandedArgv, (index += 1), arg);
315
+ else if (arg === '--responses-model' || arg === '--gpt-model')
316
+ parsed.responsesModel = readOptionValue(expandedArgv, (index += 1), arg);
317
  else if (arg === '--thinking') parsed.thinking = readOptionValue(expandedArgv, (index += 1), arg);
318
+ else if (arg === '--prompt-optimization')
319
+ parsed.promptOptimization = readOptionValue(expandedArgv, (index += 1), arg);
320
  else if (arg === '--force-web') parsed.forceWeb = true;
321
  else if (arg === '--share') parsed.share = true;
322
+ else if (arg === '--share-expires-minutes')
323
+ parsed.shareExpiresMinutes = readOptionValue(expandedArgv, (index += 1), arg);
324
+ else if (arg === '--dimension-check') parsed.dimensionCheck = true;
325
  else if (arg === '--stream-mode') parsed.streamMode = readOptionValue(expandedArgv, (index += 1), arg);
326
+ else if (arg === '--streaming-strategy')
327
+ parsed.streamingStrategy = readOptionValue(expandedArgv, (index += 1), arg);
328
  else if (arg === '--partial-images') parsed.partialImages = readOptionValue(expandedArgv, (index += 1), arg);
329
  else if (arg === '--sse-log') parsed.sseLogPath = readOptionValue(expandedArgv, (index += 1), arg);
330
  else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(expandedArgv, (index += 1), arg);
 
375
  size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'),
376
  quality: parsed.quality,
377
  output_format: normalizeOutputFormat(parsed.format),
378
+ ...(readOutputCompression(parsed) !== undefined
379
+ ? { output_compression: readOutputCompression(parsed) }
380
+ : {}),
381
  response_mode: parsed.responseMode
382
  },
383
  parsed
 
392
  size: assertValidImageSizeForModel(parsed.size, parsed.model, '--size'),
393
  quality: parsed.quality,
394
  output_format: normalizeOutputFormat(parsed.format),
395
+ ...(readOutputCompression(parsed) !== undefined
396
+ ? { output_compression: readOutputCompression(parsed) }
397
+ : {}),
398
  response_mode: parsed.responseMode
399
  },
400
  parsed
 
410
  return {
411
  ...body,
412
  ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}),
413
+ ...(parsed.responsesModel
414
+ ? { responsesModel: readNonEmptyString(parsed.responsesModel, '--responses-model') }
415
+ : {}),
416
  ...(parsed.thinking ? { thinking: parsed.thinking } : {}),
417
  ...(parsed.promptOptimization !== undefined
418
  ? { promptOptimization: readBooleanOption(parsed.promptOptimization, '--prompt-optimization') }
 
430
  if (!RESPONSE_MODES.has(parsed.responseMode)) {
431
  throw new Error('--response-mode 必须是 path、base64 或 both。');
432
  }
433
+ if (parsed.dimensionCheck && !parseImageSizeValue(parsed.size)) {
434
+ throw new Error('--dimension-check 需要 --size 为 WIDTHxHEIGHT。');
435
+ }
436
  if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
437
  throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
438
  }
 
483
  function readOutputCompression(parsed) {
484
  const outputFormat = normalizeOutputFormat(parsed.format);
485
  if (outputFormat === 'png') return undefined;
486
+ const value =
487
+ parsed.outputCompression === undefined ? String(DEFAULT_OUTPUT_COMPRESSION) : String(parsed.outputCompression);
488
  if (!/^\d+$/.test(value)) throw new Error('--output-compression 必须是 0 到 100 之间的整数。');
489
  const parsedValue = Number(value);
490
  if (!Number.isInteger(parsedValue) || parsedValue < 0 || parsedValue > 100) {
 
708
  });
709
 
710
  if (response.ok) {
711
+ await writeSuccessOutputAndExit(
712
+ enrichImageUrls(result),
713
+ options.routing,
714
+ completeScriptTiming(scriptTiming)
 
 
715
  );
 
716
  }
717
 
718
  const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
 
860
  }
861
  }
862
 
863
+ function formatPageSseOutput(result, { preserveBase64 = false } = {}) {
864
+ return formatSharedPageSseOutput({
865
+ result,
866
+ baseUrl,
867
+ responseMode: requestBody.response_mode,
868
+ defaultOutputFormat: requestBody.output_format,
869
+ preserveBase64
870
+ });
 
 
 
 
 
 
 
 
 
871
  }
872
 
873
  function createPageSseState() {
 
950
  elapsed_ms: completeScriptTiming(scriptTiming).elapsed_ms,
951
  final_image_count: state.completedImages.length
952
  });
953
+ return {
954
+ images: state.completedImages,
955
+ usage: state.usage,
956
+ actualCost: state.actualCost,
957
+ sse_diagnostics: buildPageSseDiagnostics(state)
958
+ };
959
  }
960
 
961
  function appendPageSseLog(rawEvent) {
962
  if (!options.sseLogPath || !rawEvent.trim()) return;
963
  try {
964
  fs.mkdirSync(path.dirname(options.sseLogPath), { recursive: true });
965
+ fs.appendFileSync(
966
+ options.sseLogPath,
967
+ `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`
968
+ );
969
  } catch (error) {
970
  console.warn(`SSE log write failed: ${errorMessage(error)}`);
971
  }
 
975
  if (!options.sseLogPath) return;
976
  try {
977
  fs.mkdirSync(path.dirname(options.sseLogPath), { recursive: true });
978
+ fs.appendFileSync(
979
+ options.sseLogPath,
980
+ `${JSON.stringify({ at: new Date().toISOString(), event, ...details })}\n`
981
+ );
982
  } catch (error) {
983
  console.warn(`SSE log write failed: ${errorMessage(error)}`);
984
  }
 
1091
  }
1092
  }
1093
 
1094
+ async function buildSuccessOutput(result, routing, timing, { dimensionChecked = false } = {}) {
1095
+ const checkedResult = dimensionChecked ? result : await assertDimensionsIfRequested(result);
1096
  const output = attachSummary(
1097
+ routing ? { ...checkedResult, routing } : checkedResult,
1098
  buildSuccessSummary({
1099
+ result: checkedResult,
1100
  routing,
1101
  timing,
1102
  idempotencyKey,
 
1107
  return output;
1108
  }
1109
  const shareSummary = await maybeCreateShares(output);
1110
+ return shareSummary
1111
+ ? attachSummary({ ...output, shares: shareSummary.share_results }, { ...output.summary, ...shareSummary })
1112
+ : output;
1113
+ }
1114
+
1115
+ async function writeSuccessOutputAndExit(result, routing, timing, options = {}) {
1116
+ try {
1117
+ console.log(JSON.stringify(await buildSuccessOutput(result, routing, timing, options), null, 2));
1118
+ process.exit(0);
1119
+ } catch (error) {
1120
+ if (!isDimensionCheckError(error)) throw error;
1121
+ console.error(JSON.stringify(buildDimensionCheckFailureOutput(error, routing), null, 2));
1122
+ process.exit(1);
1123
+ }
1124
+ }
1125
+
1126
+ async function assertDimensionsIfRequested(response) {
1127
+ if (!options.dimensionCheck) return response;
1128
+ return assertImageDimensions({
1129
+ response,
1130
+ expected: parseExpectedDimensions(requestBody.size),
1131
+ baseUrl,
1132
+ authHeaders,
1133
+ timeoutMs,
1134
+ readUrlFields: DIMENSION_CHECK_URL_FIELDS
1135
+ });
1136
  }
1137
 
1138
  async function maybeCreateShares(output) {
 
1155
  async function createShareForArtifact(artifactId) {
1156
  const body = {};
1157
  if (options.shareExpiresMinutes !== undefined) {
1158
+ body.expires_in_minutes = readConfiguredPositiveInteger(
1159
+ options.shareExpiresMinutes,
1160
+ '--share-expires-minutes',
1161
+ 1
1162
+ );
1163
  }
1164
  if (process.env.GPT_IMAGE_SHARE_ACCESS_CODE !== undefined) {
1165
  body.access_code = readShareAccessCodeFromEnv();
 
1244
  routing: buildPageSseRouting('manual_after_diagnosis'),
1245
  next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到其他端点。'
1246
  };
1247
+ return attachSummary(
1248
+ output,
1249
+ buildFailureSummary({
1250
+ errorBody: output,
1251
+ routing: output.routing,
1252
+ timing,
1253
+ idempotencyKey,
1254
+ billable: false,
1255
+ nextAction: 'fix_capability_or_auth'
1256
+ })
1257
+ );
1258
  }
1259
 
1260
  function buildPageSseRequestRejectedFailure(error, diagnostics, timing) {
 
1270
  routing: buildPageSseRouting('fix_request_before_retry'),
1271
  next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
1272
  };
1273
+ return attachSummary(
1274
+ output,
1275
+ buildFailureSummary({
1276
+ errorBody: output,
1277
+ routing: output.routing,
1278
+ timing,
1279
+ idempotencyKey,
1280
+ billable: false,
1281
+ nextAction: 'fix_request_before_retry'
1282
+ })
1283
+ );
1284
  }
1285
 
1286
  function buildBillablePageSseFailure(error, diagnostics, timing) {
 
1297
  next_step:
1298
  '先用 diagnose-request 诊断页面流式失败原因,再用新的 Idempotency-Key 显式选择服务端编排入口或其他诊断路径。'
1299
  };
1300
+ return attachSummary(
1301
+ output,
1302
+ buildFailureSummary({
1303
+ errorBody: output,
1304
+ routing: output.routing,
1305
+ timing,
1306
+ idempotencyKey,
1307
+ billable: true,
1308
+ nextAction: 'diagnose_then_new_idempotency_key'
1309
+ })
1310
+ );
1311
  }
1312
 
1313
  function buildFailureOutput(output, routing) {
 
1318
  routing,
1319
  timing: completeScriptTiming(scriptTiming),
1320
  idempotencyKey,
1321
+ billable: output?.billable !== false,
1322
+ nextAction: output?.next_step
1323
  })
1324
  );
1325
  }
1326
 
1327
+ function buildDimensionCheckFailureOutput(error, routing) {
1328
+ return buildFailureOutput(buildDimensionCheckFailureBody(error, routing), routing);
1329
+ }
1330
+
1331
  async function buildAgentFailureOutput(output, routing) {
1332
  const failure = buildFailureOutput(output, routing);
1333
  const enriched = await enrichFailureWithAgentDiagnostics({
 
1389
 
1390
  if (response.ok) {
1391
  const jobResult = await pollJobResult(result?.job);
1392
+ await writeSuccessOutputAndExit(enrichImageUrls(jobResult), routing, completeScriptTiming(scriptTiming));
 
 
 
 
 
 
 
1393
  }
1394
 
1395
  const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
 
1399
  await sleep(retryAfter);
1400
  }
1401
 
1402
+ console.error(JSON.stringify(buildFailureOutput({ ...lastResult, retry_after: lastRetryAfter }, routing), null, 2));
 
 
 
 
 
 
 
 
 
1403
  process.exit(1);
1404
  }
1405
 
 
1484
  timeoutMs
1485
  });
1486
  if (response.status !== 400 || result?.error?.code !== 'idempotency_key_required') {
1487
+ console.error(
1488
+ JSON.stringify({ ok: false, billable: false, endpoint, status: response.status, result }, null, 2)
1489
+ );
1490
  process.exit(1);
1491
  }
1492
  return { endpoint, status: response.status, error_code: result.error.code };
 
1511
  timeoutMs
1512
  });
1513
  if (response.status !== 400 || !text.includes('clientRequestId')) {
1514
+ console.error(
1515
+ JSON.stringify(
1516
+ { ok: false, billable: false, endpoint: PAGE_SSE_ENDPOINT, status: response.status, text },
1517
+ null,
1518
+ 2
1519
+ )
1520
+ );
1521
  process.exit(1);
1522
  }
1523
  return {
 
1723
  console.error('用法:generate-image.mjs [options] <prompt>');
1724
  console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
1725
  console.error(
1726
+ '常用参数:--model --size --quality --n --format --output-compression --response-mode --image-backend --responses-model --gpt-model --thinking --prompt-optimization --force-web --stream-mode --streaming-strategy --partial-images --share --share-expires-minutes --dimension-check --sse-log --timeout-ms --base-url --prompt-file --idempotency-key --check-remote --page-sse --agent --job --no-job(兼容别名)'
1727
  );
1728
  console.error(
1729
  '契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'
skills/gpt-image-playground-agent/scripts/lib/dimension-check.mjs ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { readImageDimensions } from './image-dimensions.mjs';
2
+ import { errorMessage, parseImageSizeValue, resolveSameOriginUrl } from './script-utils.mjs';
3
+
4
+ export const FAILURE_KIND_DIMENSION_CHECK = 'generated_artifact_failed_dimension_check';
5
+ export const DEFAULT_DIMENSION_CHECK_MAX_IMAGE_BYTES = 32 * 1024 * 1024;
6
+
7
+ const DEFAULT_DIMENSION_CHECK_NEXT_STEP =
8
+ '确认当前渠道是否支持请求尺寸,或调整任务接受实际返回尺寸;重新执行必须使用新的 Idempotency-Key。';
9
+ const DEFAULT_READ_URL_FIELDS = ['absolute_content_url', 'content_url'];
10
+
11
+ export class DimensionCheckError extends Error {
12
+ constructor(message, details = {}) {
13
+ super(message);
14
+ this.name = 'DimensionCheckError';
15
+ this.code = 'dimension_check_failed';
16
+ this.retryable = false;
17
+ this.billable = true;
18
+ this.nextStep = details.nextStep || DEFAULT_DIMENSION_CHECK_NEXT_STEP;
19
+ this.expectedDimensions = details.expected;
20
+ this.actualDimensions = details.actual;
21
+ this.response = sanitizeImageResponse(details.response);
22
+ }
23
+ }
24
+
25
+ export function isDimensionCheckError(error) {
26
+ return Boolean(error && typeof error === 'object' && error.code === 'dimension_check_failed');
27
+ }
28
+
29
+ export function parseExpectedDimensions(size) {
30
+ return size && size !== 'auto' ? parseImageSizeValue(size) : undefined;
31
+ }
32
+
33
+ export function sanitizeImageResponse(response) {
34
+ if (!response || !Array.isArray(response.images)) return response;
35
+ return {
36
+ ...response,
37
+ images: response.images.map((image) => {
38
+ if (!image || typeof image !== 'object' || !Object.prototype.hasOwnProperty.call(image, 'b64_json')) {
39
+ return image;
40
+ }
41
+ const { b64_json: b64Json, ...safeImage } = image;
42
+ return {
43
+ ...safeImage,
44
+ ...(typeof b64Json === 'string' ? { b64_json_length: b64Json.length } : {})
45
+ };
46
+ })
47
+ };
48
+ }
49
+
50
+ export async function assertImageDimensions({
51
+ response,
52
+ expected,
53
+ baseUrl,
54
+ authHeaders,
55
+ timeoutMs,
56
+ maxImageBytes = DEFAULT_DIMENSION_CHECK_MAX_IMAGE_BYTES,
57
+ messagePrefix = '尺寸校验失败',
58
+ missingSizeMessage = '--dimension-check 需要 size 为 WIDTHxHEIGHT。',
59
+ nextStep,
60
+ readUrlFields = DEFAULT_READ_URL_FIELDS
61
+ }) {
62
+ if (!expected) {
63
+ throw new DimensionCheckError(missingSizeMessage, { nextStep });
64
+ }
65
+ if (!Array.isArray(response?.images) || response.images.length === 0) {
66
+ throw new DimensionCheckError(`${messagePrefix}:响应中没有可验收的图片。`, {
67
+ expected,
68
+ response,
69
+ nextStep
70
+ });
71
+ }
72
+
73
+ const images = [];
74
+ let mismatch;
75
+ for (const image of response.images) {
76
+ let actual;
77
+ try {
78
+ const bytes = await readImageBytes({
79
+ image,
80
+ baseUrl,
81
+ authHeaders,
82
+ timeoutMs,
83
+ maxImageBytes,
84
+ readUrlFields
85
+ });
86
+ actual = readImageDimensions(bytes);
87
+ } catch (error) {
88
+ throw new DimensionCheckError(`${messagePrefix}:${errorMessage(error)}`, {
89
+ expected,
90
+ response: { ...response, images: [...images, sanitizeImageResponse({ images: [image] }).images[0]] },
91
+ nextStep
92
+ });
93
+ }
94
+ images.push({ ...image, dimensions: actual });
95
+ if (actual.width !== expected.width || actual.height !== expected.height) {
96
+ mismatch ??= actual;
97
+ }
98
+ }
99
+ if (mismatch) {
100
+ throw new DimensionCheckError(
101
+ `${messagePrefix}:期望 ${expected.width}x${expected.height},实际 ${mismatch.width}x${mismatch.height}。`,
102
+ { expected, actual: mismatch, response: { ...response, images }, nextStep }
103
+ );
104
+ }
105
+ return { ...response, images };
106
+ }
107
+
108
+ export function buildDimensionCheckFailureBody(error, routing) {
109
+ return {
110
+ billable: error.billable !== false,
111
+ error: {
112
+ code: error.code,
113
+ message: error.message,
114
+ retryable: error.retryable,
115
+ expected_dimensions: readDimensionObject(error.expectedDimensions),
116
+ actual_dimensions: readDimensionObject(error.actualDimensions)
117
+ },
118
+ validation_failure_kind: FAILURE_KIND_DIMENSION_CHECK,
119
+ response: error.response,
120
+ routing,
121
+ ...(typeof error?.nextStep === 'string' ? { next_step: error.nextStep } : {})
122
+ };
123
+ }
124
+
125
+ async function readImageBytes({ image, baseUrl, authHeaders, timeoutMs, maxImageBytes, readUrlFields }) {
126
+ if (image.b64_json) {
127
+ assertBase64ImageSizeWithinLimit(image.b64_json, maxImageBytes);
128
+ const bytes = Buffer.from(image.b64_json, 'base64');
129
+ assertImageBytesWithinLimit(bytes.length, maxImageBytes);
130
+ return bytes;
131
+ }
132
+ const source = readFirstUrl(image, readUrlFields);
133
+ if (!source) throw new Error(`dimension-check 需要 b64_json 或 ${formatUrlFieldList(readUrlFields)}。`);
134
+ const resolved = resolveSameOriginUrl(baseUrl, source.url, source.field);
135
+ const headers = typeof authHeaders === 'function' ? authHeaders() : authHeaders;
136
+ const { response, bytes } = await fetchBytes(resolved, headers, timeoutMs, maxImageBytes);
137
+ if (!response.ok) throw new Error(`下载产物失败,状态码 ${response.status}。`);
138
+ return bytes;
139
+ }
140
+
141
+ function formatUrlFieldList(fields) {
142
+ const readable = normalizeUrlFields(fields);
143
+ return readable.length > 0 ? readable.join(' 或 ') : DEFAULT_READ_URL_FIELDS.join(' 或 ');
144
+ }
145
+
146
+ function readFirstUrl(image, fields) {
147
+ for (const field of normalizeUrlFields(fields)) {
148
+ if (typeof image?.[field] === 'string' && image[field]) return { field, url: image[field] };
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ function normalizeUrlFields(fields) {
154
+ return Array.isArray(fields)
155
+ ? fields.filter((field) => typeof field === 'string' && field)
156
+ : DEFAULT_READ_URL_FIELDS;
157
+ }
158
+
159
+ function readDimensionObject(value) {
160
+ if (!value || typeof value !== 'object') return null;
161
+ const width = Number(value.width);
162
+ const height = Number(value.height);
163
+ if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width <= 0 || height <= 0) return null;
164
+ return { width, height };
165
+ }
166
+
167
+ async function fetchBytes(
168
+ url,
169
+ headers = {},
170
+ timeoutMs = 420000,
171
+ maxImageBytes = DEFAULT_DIMENSION_CHECK_MAX_IMAGE_BYTES
172
+ ) {
173
+ const controller = new AbortController();
174
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
175
+ try {
176
+ const signal = controller.signal;
177
+ const response = await fetch(url, { headers, signal });
178
+ return { response, bytes: await readResponseBytes(response, { maxImageBytes, signal, timeoutMs }) };
179
+ } catch (error) {
180
+ if (error?.name === 'AbortError' || error?.code === 'download_timeout')
181
+ throw createDownloadTimeoutError(timeoutMs);
182
+ throw error;
183
+ } finally {
184
+ clearTimeout(timeout);
185
+ }
186
+ }
187
+
188
+ async function readResponseBytes(response, { maxImageBytes, signal, timeoutMs }) {
189
+ const contentLength = Number(response.headers.get('content-length'));
190
+ if (Number.isSafeInteger(contentLength)) assertImageBytesWithinLimit(contentLength, maxImageBytes);
191
+ if (!response.body) {
192
+ const bytes = Buffer.from(await response.arrayBuffer());
193
+ assertImageBytesWithinLimit(bytes.length, maxImageBytes);
194
+ return bytes;
195
+ }
196
+
197
+ const reader = response.body.getReader();
198
+ const chunks = [];
199
+ let total = 0;
200
+ try {
201
+ while (true) {
202
+ const { done, value } = await readChunkWithAbort(reader, signal, timeoutMs);
203
+ if (done) break;
204
+ const chunk = Buffer.from(value);
205
+ total += chunk.length;
206
+ if (total > maxImageBytes) {
207
+ await reader.cancel();
208
+ assertImageBytesWithinLimit(total, maxImageBytes);
209
+ }
210
+ chunks.push(chunk);
211
+ }
212
+ } finally {
213
+ reader.releaseLock();
214
+ }
215
+ return Buffer.concat(chunks, total);
216
+ }
217
+
218
+ function readChunkWithAbort(reader, signal, timeoutMs) {
219
+ if (!signal) return reader.read();
220
+ if (signal.aborted) return Promise.reject(createDownloadTimeoutError(timeoutMs));
221
+ return new Promise((resolve, reject) => {
222
+ const onAbort = () => {
223
+ reader.cancel().catch(() => {});
224
+ reject(createDownloadTimeoutError(timeoutMs));
225
+ };
226
+ signal.addEventListener('abort', onAbort, { once: true });
227
+ reader
228
+ .read()
229
+ .then(resolve, reject)
230
+ .finally(() => {
231
+ signal.removeEventListener('abort', onAbort);
232
+ });
233
+ });
234
+ }
235
+
236
+ function createDownloadTimeoutError(timeoutMs) {
237
+ const error = new Error(`下载产物超时,已等待 ${timeoutMs}ms。`);
238
+ error.code = 'download_timeout';
239
+ return error;
240
+ }
241
+
242
+ function assertBase64ImageSizeWithinLimit(value, maxImageBytes) {
243
+ const estimatedBytes = estimateBase64DecodedBytes(value);
244
+ assertImageBytesWithinLimit(estimatedBytes, maxImageBytes);
245
+ }
246
+
247
+ function estimateBase64DecodedBytes(value) {
248
+ const normalized = String(value).replace(/\s/g, '');
249
+ const padding = normalized.endsWith('==') ? 2 : normalized.endsWith('=') ? 1 : 0;
250
+ return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
251
+ }
252
+
253
+ function assertImageBytesWithinLimit(byteLength, maxImageBytes) {
254
+ if (!Number.isSafeInteger(maxImageBytes) || maxImageBytes < 1) {
255
+ throw new Error('dimension-check 图片大小上限配置无效。');
256
+ }
257
+ if (byteLength <= maxImageBytes) return;
258
+ throw new Error(`图片数据超过 ${maxImageBytes} 字节限制。`);
259
+ }
skills/gpt-image-playground-agent/scripts/lib/image-dimensions.mjs ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export function readImageDimensions(buffer) {
2
+ if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
3
+ throw new Error('图片数据为空,无法读取尺寸。');
4
+ }
5
+ if (hasPngSignature(buffer)) {
6
+ return readPngDimensions(buffer);
7
+ }
8
+ if (hasWebpSignature(buffer)) {
9
+ return readWebpDimensions(buffer);
10
+ }
11
+ if (hasJpegSignature(buffer)) {
12
+ return readJpegDimensions(buffer);
13
+ }
14
+ throw new Error('无法识别图片格式。');
15
+ }
16
+
17
+ function hasPngSignature(buffer) {
18
+ return (
19
+ buffer.length >= 8 &&
20
+ buffer[0] === 0x89 &&
21
+ buffer.toString('ascii', 1, 4) === 'PNG' &&
22
+ buffer[4] === 0x0d &&
23
+ buffer[5] === 0x0a &&
24
+ buffer[6] === 0x1a &&
25
+ buffer[7] === 0x0a
26
+ );
27
+ }
28
+
29
+ function hasWebpSignature(buffer) {
30
+ return (
31
+ buffer.length >= 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP'
32
+ );
33
+ }
34
+
35
+ function hasJpegSignature(buffer) {
36
+ return buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xd8;
37
+ }
38
+
39
+ function readPngDimensions(buffer) {
40
+ if (buffer.length < 24) throw new Error('PNG 图片数据截断,无法读取尺寸。');
41
+ if (buffer.toString('ascii', 12, 16) !== 'IHDR') throw new Error('无法读取 PNG 图片尺寸。');
42
+ const width = buffer.readUInt32BE(16);
43
+ const height = buffer.readUInt32BE(20);
44
+ assertPositiveDimensions(width, height, 'PNG');
45
+ return { width, height };
46
+ }
47
+
48
+ function readJpegDimensions(buffer) {
49
+ let offset = 2;
50
+ while (offset < buffer.length) {
51
+ while (offset < buffer.length && buffer[offset] === 0xff) offset += 1;
52
+ if (offset >= buffer.length) throw new Error('JPEG 图片数据截断,无法读取尺寸。');
53
+
54
+ const marker = buffer[offset];
55
+ offset += 1;
56
+ if (marker === 0xd9 || marker === 0xda) break;
57
+ if (isStandaloneJpegMarker(marker)) continue;
58
+
59
+ if (offset + 2 > buffer.length) throw new Error('JPEG 图片数据截断,无法读取尺寸。');
60
+ const length = buffer.readUInt16BE(offset);
61
+ if (length < 2) throw new Error('JPEG 图片段长度无效,无法读取尺寸。');
62
+ const segmentStart = offset + 2;
63
+ const segmentEnd = offset + length;
64
+ if (segmentEnd > buffer.length) throw new Error('JPEG 图片数据截断,无法读取尺寸。');
65
+
66
+ if (isJpegStartOfFrame(marker)) {
67
+ if (length < 8) throw new Error('JPEG 图片数据截断,无法读取尺寸。');
68
+ const height = buffer.readUInt16BE(segmentStart + 1);
69
+ const width = buffer.readUInt16BE(segmentStart + 3);
70
+ assertPositiveDimensions(width, height, 'JPEG');
71
+ return { width, height };
72
+ }
73
+ offset = segmentEnd;
74
+ }
75
+ throw new Error('无法读取 JPEG 图片尺寸。');
76
+ }
77
+
78
+ function isStandaloneJpegMarker(marker) {
79
+ return marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8);
80
+ }
81
+
82
+ function isJpegStartOfFrame(marker) {
83
+ return (
84
+ (marker >= 0xc0 && marker <= 0xc3) ||
85
+ (marker >= 0xc5 && marker <= 0xc7) ||
86
+ (marker >= 0xc9 && marker <= 0xcb) ||
87
+ (marker >= 0xcd && marker <= 0xcf)
88
+ );
89
+ }
90
+
91
+ function readWebpDimensions(buffer) {
92
+ if (buffer.length < 16) throw new Error('WebP 图片数据截断,无法读取尺寸。');
93
+ const chunk = buffer.toString('ascii', 12, 16);
94
+ if (chunk === 'VP8X') {
95
+ if (buffer.length < 30) throw new Error('WebP 图片数据截断,无法读取尺寸。');
96
+ const width = 1 + buffer.readUIntLE(24, 3);
97
+ const height = 1 + buffer.readUIntLE(27, 3);
98
+ assertPositiveDimensions(width, height, 'WebP');
99
+ return { width, height };
100
+ }
101
+ if (chunk === 'VP8L') {
102
+ if (buffer.length < 25) throw new Error('WebP 图片数据截断,无法读取尺寸。');
103
+ const bits = buffer.readUInt32LE(21);
104
+ const width = (bits & 0x3fff) + 1;
105
+ const height = ((bits >> 14) & 0x3fff) + 1;
106
+ assertPositiveDimensions(width, height, 'WebP');
107
+ return { width, height };
108
+ }
109
+ if (chunk === 'VP8 ') {
110
+ if (buffer.length < 30) throw new Error('WebP 图片数据截断,无法读取尺寸。');
111
+ const width = buffer.readUInt16LE(26) & 0x3fff;
112
+ const height = buffer.readUInt16LE(28) & 0x3fff;
113
+ assertPositiveDimensions(width, height, 'WebP');
114
+ return { width, height };
115
+ }
116
+ throw new Error('无法读取 WebP 图片尺寸。');
117
+ }
118
+
119
+ function assertPositiveDimensions(width, height, format) {
120
+ if (width > 0 && height > 0) return;
121
+ throw new Error(`${format} 图片尺寸无效。`);
122
+ }
skills/gpt-image-playground-agent/scripts/lib/page-sse-client.mjs CHANGED
@@ -5,461 +5,483 @@ export const PAGE_SSE_ENDPOINT = '/api/images';
5
  export const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
6
 
7
  export function readPageSseClientRequestIdMaxLength(capabilities) {
8
- const maxLength = capabilities?.agent_streaming?.page_sse?.client_request_id?.max_length;
9
- if (Number.isSafeInteger(maxLength) && maxLength > 0) return maxLength;
10
- return DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH;
11
  }
12
 
13
  export function assertPageSseReady({ capabilities, passwordHash, idempotencyKey }) {
14
- const pageSse = capabilities?.agent_streaming?.page_sse;
15
- if (pageSse?.supported !== true) {
16
- throw createPageSseScriptError(
17
- 'page_sse_unavailable',
18
- '当前路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级。'
19
- );
20
- }
21
- if (pageSse?.auth?.required === true && !passwordHash) {
22
- throw createPageSseScriptError(
23
- 'page_sse_auth_required',
24
- '页面 SSE 路径需要表单字段 passwordHash;请在本机私有 .env.agent.local 中设置 GPT_IMAGE_APP_PASSWORD_HASH,或导出该环境变量后重试。只有 GPT_IMAGE_AGENT_TOKEN 不能用于页面 SSE 表单鉴权。'
25
- );
26
- }
27
- const maxLength = readPageSseClientRequestIdMaxLength(capabilities);
28
- if (idempotencyKey.length > maxLength) {
29
- throw createPageSseScriptError(
30
- 'page_sse_client_request_id_too_long',
31
- `页面 SSE 的 clientRequestId 不能超过 ${maxLength} 个字符;请缩短 Idempotency-Key。`
32
- );
33
- }
34
  }
35
 
36
  export async function postPageSse({ url, formData, timeoutMs, errorMessage, sseLogPath }) {
37
- const controller = new AbortController();
38
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
39
- const startedAtMs = Date.now();
40
- appendPageSseTrace(sseLogPath, 'request_started', {
41
- client_request_id: readFormDataString(formData, 'clientRequestId'),
42
- endpoint: PAGE_SSE_ENDPOINT
43
- });
44
- try {
45
- let response;
46
- try {
47
- response = await fetch(url, {
48
- method: 'POST',
49
- body: formData,
50
- signal: controller.signal
51
- });
52
- } catch (error) {
53
- appendPageSseTrace(sseLogPath, 'request_failed', {
54
  client_request_id: readFormDataString(formData, 'clientRequestId'),
55
- endpoint: PAGE_SSE_ENDPOINT,
56
- elapsed_ms: Date.now() - startedAtMs,
57
- error: errorMessage(error)
58
- });
59
- throw new Error(`请求失败:${url}。${errorMessage(error)}`);
60
- }
61
-
62
- const contentType = response.headers.get('content-type') || '';
63
- if (contentType.includes('text/event-stream')) {
64
- try {
65
- return await collectPageSseResult(response, controller.signal, errorMessage, sseLogPath, {
66
- startedAtMs,
67
- clientRequestId: readFormDataString(formData, 'clientRequestId')
68
- });
69
- } catch (error) {
70
- appendPageSseTrace(sseLogPath, 'request_failed', {
71
- client_request_id: readFormDataString(formData, 'clientRequestId'),
72
- endpoint: PAGE_SSE_ENDPOINT,
73
- elapsed_ms: Date.now() - startedAtMs,
74
- error: errorMessage(error)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  });
76
- throw error;
77
- }
78
- }
79
-
80
- const text = await response.text();
81
- if (!response.ok) {
82
- appendPageSseTrace(sseLogPath, 'request_failed', {
83
- client_request_id: readFormDataString(formData, 'clientRequestId'),
84
- endpoint: PAGE_SSE_ENDPOINT,
85
- elapsed_ms: Date.now() - startedAtMs,
86
- status: response.status
87
- });
88
- throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text);
89
  }
90
- const result = parseJsonResponse(text, true, url, errorMessage);
91
- appendPageSseTrace(sseLogPath, 'request_completed', {
92
- client_request_id: readFormDataString(formData, 'clientRequestId'),
93
- endpoint: PAGE_SSE_ENDPOINT,
94
- elapsed_ms: Date.now() - startedAtMs
95
- });
96
- return result;
97
- } finally {
98
- clearTimeout(timeout);
99
- }
100
  }
101
 
102
- export function formatPageSseOutput({ result, baseUrl, responseMode = 'path', defaultOutputFormat = 'png' }) {
103
- if (!result || !Array.isArray(result.images)) return result;
104
- return {
105
- ...result,
106
- images: result.images.map((image) =>
107
- formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat })
108
- )
109
- };
110
- }
111
-
112
- export function buildPageSseFailureOutput({ error, fallbackEndpoint, fallbackMode = 'manual_after_diagnosis', errorMessage }) {
113
- const diagnostics = readPageSseDiagnostics(error);
114
- if (isPageSseScriptError(error)) {
115
  return {
116
- ok: false,
117
- billable: false,
118
- error: {
119
- code: error.scriptCode,
120
- message: errorMessage(error),
121
- ...(diagnostics ? { diagnostics } : {})
122
- },
123
- routing: buildPageSseRouting(fallbackEndpoint, fallbackMode),
124
- next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。'
125
  };
126
- }
127
- if (isPageSseRequestRejected(error)) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
128
  return {
129
- ok: false,
130
- billable: false,
131
- error: {
132
- code: 'page_sse_request_rejected',
133
- status: error.status,
134
- message: errorMessage(error),
135
- ...(diagnostics ? { diagnostics } : {})
136
- },
137
- routing: buildPageSseRouting(fallbackEndpoint, 'fix_request_before_retry'),
138
- next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
 
139
  };
140
- }
141
- return {
142
- ok: false,
143
- billable: true,
144
- error: {
145
- code: 'page_sse_failed',
146
- ...buildPageSseFailureStatus(error),
147
- message: errorMessage(error),
148
- ...(diagnostics ? { diagnostics } : {})
149
- },
150
- routing: buildPageSseRouting(fallbackEndpoint, fallbackMode),
151
- next_step:
152
- '先用 diagnose-request 诊断页面流式失败原因,再用新的 Idempotency-Key 显式选择备用路径;若改用 Agent JSON/edit 对照,必须重新校验输出尺寸和格式。'
153
- };
154
  }
155
 
156
  export function normalizeImageBackendForPage(value) {
157
- if (value === 'images') return 'images-api';
158
- if (value === 'responses') return 'responses-image-generation';
159
- return value;
160
  }
161
 
162
  export function isPageSseDisabledByStreamingOptions(value) {
163
- return value?.streamMode === 'non_stream' || value?.streamingStrategy === 'off';
164
  }
165
 
166
  export function assertPageSseStreamingAllowed(value) {
167
- if (isPageSseDisabledByStreamingOptions(value)) {
168
- throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能使用页面 SSE。');
169
- }
170
  }
171
 
172
  function buildPageSseRouting(fallbackEndpoint, fallbackMode) {
173
- return {
174
- transport: 'page_sse',
175
- endpoint: PAGE_SSE_ENDPOINT,
176
- fallback_endpoint: fallbackEndpoint,
177
- fallback_mode: fallbackMode
178
- };
179
  }
180
 
181
  function createPageSseScriptError(code, message) {
182
- const error = new Error(message);
183
- error.scriptCode = code;
184
- return error;
185
  }
186
 
187
  function isPageSseScriptError(error) {
188
- return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string');
189
  }
190
 
191
  function isPageSseRequestRejected(error) {
192
- return Boolean(error && typeof error === 'object' && Number.isInteger(error.status) && error.status >= 400 && error.status < 500);
 
 
 
 
 
 
193
  }
194
 
195
  function createPageSseHttpError(status, message) {
196
- const error = new Error(formatErrorValue(message));
197
- error.status = status;
198
- return error;
199
  }
200
 
201
  function formatErrorValue(value) {
202
- if (typeof value === 'string' && value.trim()) return value;
203
- if (value && typeof value === 'object') {
204
- if (typeof value.message === 'string' && value.message.trim()) return value.message;
205
- if (typeof value.code === 'string' && value.code.trim()) return value.code;
206
- try {
207
- return JSON.stringify(value);
208
- } catch {
209
- return '页面 SSE 返回错误。';
 
210
  }
211
- }
212
- return '页面 SSE 返回错误。';
213
  }
214
 
215
  function readErrorFromJsonText(text) {
216
- try {
217
- const value = text ? JSON.parse(text) : null;
218
- if (typeof value?.error === 'string') return value.error;
219
- return value?.error || value;
220
- } catch {
221
- return undefined;
222
- }
223
  }
224
 
225
  function parseJsonResponse(text, allowEmpty, url, errorMessage) {
226
- if (!text && allowEmpty) return {};
227
- try {
228
- return JSON.parse(text);
229
- } catch (error) {
230
- throw new Error(`响应不是有效 JSON:${url}。${errorMessage(error)}`);
231
- }
232
  }
233
 
234
  async function collectPageSseResult(response, signal, errorMessage, sseLogPath, trace = {}) {
235
- const reader = response.body?.getReader();
236
- if (!reader) throw new Error('页面 SSE 响应缺少 body。');
237
- const decoder = new TextDecoder();
238
- const state = createPageSseState();
239
- let buffer = '';
240
- while (true) {
241
- const { done, value } = await readPageSseChunk(reader, signal);
242
- if (done) break;
243
- buffer += decoder.decode(value, { stream: true });
244
- const events = buffer.split(/\r?\n\r?\n/);
245
- buffer = events.pop() || '';
246
- for (const rawEvent of events) {
247
- appendPageSseLog(sseLogPath, rawEvent);
248
- applyPageSseEvent(state, rawEvent, errorMessage);
 
249
  }
250
- }
251
- buffer += decoder.decode();
252
- if (buffer.trim()) {
253
- appendPageSseLog(sseLogPath, buffer);
254
- applyPageSseEvent(state, buffer, errorMessage);
255
- }
256
- if (state.completedImages.length === 0) {
257
- throw withPageSseDiagnostics(new Error('页面 SSE 未返回最终图片。'), state);
258
- }
259
- if (!state.doneReceived) {
260
- throw withPageSseDiagnostics(new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'), state);
261
- }
262
- appendPageSseTrace(sseLogPath, 'request_completed', {
263
- client_request_id: trace.clientRequestId,
264
- endpoint: PAGE_SSE_ENDPOINT,
265
- elapsed_ms: Date.now() - trace.startedAtMs,
266
- final_image_count: state.completedImages.length
267
- });
268
- return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost, sse_diagnostics: buildPageSseDiagnostics(state) };
 
 
 
 
269
  }
270
 
271
  function appendPageSseLog(filePath, rawEvent) {
272
- if (!filePath || !rawEvent.trim()) return;
273
- try {
274
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
275
- fs.appendFileSync(filePath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`);
276
- } catch (error) {
277
- console.warn(`SSE log write failed: ${error instanceof Error ? error.message : String(error)}`);
278
- }
279
  }
280
 
281
  function appendPageSseTrace(filePath, event, details) {
282
- if (!filePath) return;
283
- try {
284
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
285
- fs.appendFileSync(filePath, `${JSON.stringify({ at: new Date().toISOString(), event, ...details })}\n`);
286
- } catch (error) {
287
- console.warn(`SSE log write failed: ${error instanceof Error ? error.message : String(error)}`);
288
- }
289
  }
290
 
291
  function readFormDataString(formData, name) {
292
- const value = formData?.get?.(name);
293
- return typeof value === 'string' ? value : undefined;
294
  }
295
 
296
  function createPageSseState() {
297
- return {
298
- completedImages: [],
299
- usage: undefined,
300
- actualCost: undefined,
301
- doneReceived: false,
302
- completedEventCount: 0,
303
- partialImageCount: 0,
304
- lastEventType: undefined
305
- };
306
  }
307
 
308
  function readPageSseChunk(reader, signal) {
309
- if (!signal) return reader.read();
310
- if (signal.aborted) return Promise.reject(new Error('请求超时。'));
311
- return new Promise((resolve, reject) => {
312
- const onAbort = () => reject(new Error('请求超时。'));
313
- signal.addEventListener('abort', onAbort, { once: true });
314
- reader
315
- .read()
316
- .then(resolve, reject)
317
- .finally(() => {
318
- signal.removeEventListener('abort', onAbort);
319
- });
320
- });
321
  }
322
 
323
  function applyPageSseEvent(state, rawEvent, errorMessage) {
324
- const event = parsePageSseEvent(rawEvent, errorMessage);
325
- if (!event) return;
326
- const eventType = readPageSseEventType(event);
327
- state.lastEventType = eventType;
328
- if (isPartialPageSseEvent(event, eventType)) state.partialImageCount += 1;
329
- if (event.type === 'error') {
330
- throw createPageSseStreamError(event, state);
331
- }
332
- if (event.type === 'completed' && event.filename) {
333
- state.completedEventCount += 1;
334
- state.completedImages.push(
335
- normalizePageSseImage(
336
- {
337
- filename: event.filename,
338
- b64_json: event.b64_json,
339
- path: event.path,
340
- output_format: event.outputFormat || event.output_format
341
- },
342
- readPageSseClientRequestId(event)
343
- )
344
- );
345
- return;
346
- }
347
- if (event.type === 'done') {
348
- state.doneReceived = true;
349
- const clientRequestId = readPageSseClientRequestId(event);
350
- state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId);
351
- state.usage = event.usage;
352
- state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost;
353
- }
354
  }
355
 
356
  function readPageSseEventType(event) {
357
- if (typeof event.type === 'string' && event.type.trim()) return event.type;
358
- if (typeof event.event === 'string' && event.event.trim()) return event.event;
359
- return undefined;
360
  }
361
 
362
  function isPartialPageSseEvent(event, eventType) {
363
- if (typeof eventType === 'string' && eventType.includes('partial_image')) return true;
364
- return Boolean(event.partial_image || event.partialImage || event.partial_image_b64 || event.partialImageB64);
365
  }
366
 
367
  function parsePageSseEvent(rawEvent, errorMessage) {
368
- const lines = rawEvent.split(/\r?\n/);
369
- const data = lines
370
- .filter((line) => line.startsWith('data: '))
371
- .map((line) => line.slice(6))
372
- .join('\n')
373
- .trim();
374
- if (!data || data === '[DONE]') return undefined;
375
- try {
376
- return JSON.parse(data);
377
- } catch (error) {
378
- throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`);
379
- }
380
  }
381
 
382
  function createPageSseStreamError(event, state) {
383
- const error = new Error(formatErrorValue(event.error));
384
- const status = readPageSseStreamStatus(event);
385
- if (Number.isInteger(status)) error.streamStatus = status;
386
- return withPageSseDiagnostics(error, state);
387
  }
388
 
389
  function readPageSseStreamStatus(event) {
390
- if (Number.isInteger(event.status)) return event.status;
391
- if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) {
392
- return event.error.status;
393
- }
394
- return undefined;
395
  }
396
 
397
  function readPageSseClientRequestId(event) {
398
- if (typeof event.clientRequestId === 'string') return event.clientRequestId;
399
- if (typeof event.client_request_id === 'string') return event.client_request_id;
400
- return undefined;
401
  }
402
 
403
  function normalizePageSseImage(image, fallbackClientRequestId) {
404
- const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId;
405
- return {
406
- ...image,
407
- ...(image.output_format ? { output_format: image.output_format } : {}),
408
- ...(clientRequestId ? { clientRequestId } : {})
409
- };
410
  }
411
 
412
  function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
413
- if (!Array.isArray(doneImages) || doneImages.length === 0) {
414
- return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId));
415
- }
416
- const imageCount = Math.max(doneImages.length, completedImages.length);
417
- return Array.from({ length: imageCount }, (_, index) =>
418
- normalizePageSseImage(
419
- { ...(completedImages[index] || {}), ...(doneImages[index] || {}) },
420
- fallbackClientRequestId
421
- )
422
- );
423
- }
424
-
425
- function formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat }) {
426
- const output = {
427
- ...image,
428
- output_format: image.outputFormat || image.output_format || defaultOutputFormat
429
- };
430
- if (output.path) {
431
- output.absolute_path = new URL(output.path, `${baseUrl}/`).toString();
432
- output.content_url = output.content_url || output.path;
433
- output.absolute_content_url = output.absolute_content_url || output.absolute_path;
434
- if (responseMode === 'path') delete output.b64_json;
435
- }
436
- return output;
437
  }
438
 
439
  function buildPageSseFailureStatus(error) {
440
- if (error && typeof error === 'object') {
441
- if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
442
- if (Number.isInteger(error.status)) return { status: error.status };
443
- }
444
- return {};
445
  }
446
 
447
  function withPageSseDiagnostics(error, state) {
448
- error.pageSseDiagnostics = buildPageSseDiagnostics(state);
449
- return error;
450
  }
451
 
452
  function readPageSseDiagnostics(error) {
453
- if (!error || typeof error !== 'object' || !error.pageSseDiagnostics) return undefined;
454
- return error.pageSseDiagnostics;
455
  }
456
 
457
  function buildPageSseDiagnostics(state) {
458
- return {
459
- partial_image_count: state.partialImageCount,
460
- completed_event_count: state.completedEventCount,
461
- done_received: state.doneReceived,
462
- final_image_count: state.completedImages.length,
463
- ...(state.lastEventType ? { last_upstream_event_type: state.lastEventType } : {})
464
- };
465
  }
 
5
  export const DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128;
6
 
7
  export function readPageSseClientRequestIdMaxLength(capabilities) {
8
+ const maxLength = capabilities?.agent_streaming?.page_sse?.client_request_id?.max_length;
9
+ if (Number.isSafeInteger(maxLength) && maxLength > 0) return maxLength;
10
+ return DEFAULT_PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH;
11
  }
12
 
13
  export function assertPageSseReady({ capabilities, passwordHash, idempotencyKey }) {
14
+ const pageSse = capabilities?.agent_streaming?.page_sse;
15
+ if (pageSse?.supported !== true) {
16
+ throw createPageSseScriptError(
17
+ 'page_sse_unavailable',
18
+ '当前路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级。'
19
+ );
20
+ }
21
+ if (pageSse?.auth?.required === true && !passwordHash) {
22
+ throw createPageSseScriptError(
23
+ 'page_sse_auth_required',
24
+ '页面 SSE 路径需要表单字段 passwordHash;请在本机私有 .env.agent.local 中设置 GPT_IMAGE_APP_PASSWORD_HASH,或导出该环境变量后重试。只有 GPT_IMAGE_AGENT_TOKEN 不能用于页面 SSE 表单鉴权。'
25
+ );
26
+ }
27
+ const maxLength = readPageSseClientRequestIdMaxLength(capabilities);
28
+ if (idempotencyKey.length > maxLength) {
29
+ throw createPageSseScriptError(
30
+ 'page_sse_client_request_id_too_long',
31
+ `页面 SSE 的 clientRequestId 不能超过 ${maxLength} 个字符;请缩短 Idempotency-Key。`
32
+ );
33
+ }
34
  }
35
 
36
  export async function postPageSse({ url, formData, timeoutMs, errorMessage, sseLogPath }) {
37
+ const controller = new AbortController();
38
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
39
+ const startedAtMs = Date.now();
40
+ appendPageSseTrace(sseLogPath, 'request_started', {
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  client_request_id: readFormDataString(formData, 'clientRequestId'),
42
+ endpoint: PAGE_SSE_ENDPOINT
43
+ });
44
+ try {
45
+ let response;
46
+ try {
47
+ response = await fetch(url, {
48
+ method: 'POST',
49
+ body: formData,
50
+ signal: controller.signal
51
+ });
52
+ } catch (error) {
53
+ appendPageSseTrace(sseLogPath, 'request_failed', {
54
+ client_request_id: readFormDataString(formData, 'clientRequestId'),
55
+ endpoint: PAGE_SSE_ENDPOINT,
56
+ elapsed_ms: Date.now() - startedAtMs,
57
+ error: errorMessage(error)
58
+ });
59
+ throw new Error(`请求失败:${url}。${errorMessage(error)}`);
60
+ }
61
+
62
+ const contentType = response.headers.get('content-type') || '';
63
+ if (contentType.includes('text/event-stream')) {
64
+ try {
65
+ return await collectPageSseResult(response, controller.signal, errorMessage, sseLogPath, {
66
+ startedAtMs,
67
+ clientRequestId: readFormDataString(formData, 'clientRequestId')
68
+ });
69
+ } catch (error) {
70
+ appendPageSseTrace(sseLogPath, 'request_failed', {
71
+ client_request_id: readFormDataString(formData, 'clientRequestId'),
72
+ endpoint: PAGE_SSE_ENDPOINT,
73
+ elapsed_ms: Date.now() - startedAtMs,
74
+ error: errorMessage(error)
75
+ });
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ const text = await response.text();
81
+ if (!response.ok) {
82
+ appendPageSseTrace(sseLogPath, 'request_failed', {
83
+ client_request_id: readFormDataString(formData, 'clientRequestId'),
84
+ endpoint: PAGE_SSE_ENDPOINT,
85
+ elapsed_ms: Date.now() - startedAtMs,
86
+ status: response.status
87
+ });
88
+ throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text);
89
+ }
90
+ const result = parseJsonResponse(text, true, url, errorMessage);
91
+ appendPageSseTrace(sseLogPath, 'request_completed', {
92
+ client_request_id: readFormDataString(formData, 'clientRequestId'),
93
+ endpoint: PAGE_SSE_ENDPOINT,
94
+ elapsed_ms: Date.now() - startedAtMs
95
  });
96
+ return result;
97
+ } finally {
98
+ clearTimeout(timeout);
 
 
 
 
 
 
 
 
 
 
99
  }
 
 
 
 
 
 
 
 
 
 
100
  }
101
 
102
+ export function formatPageSseOutput({
103
+ result,
104
+ baseUrl,
105
+ responseMode = 'path',
106
+ defaultOutputFormat = 'png',
107
+ preserveBase64 = false
108
+ }) {
109
+ if (!result || !Array.isArray(result.images)) return result;
 
 
 
 
 
110
  return {
111
+ ...result,
112
+ images: result.images.map((image) =>
113
+ formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat, preserveBase64 })
114
+ )
 
 
 
 
 
115
  };
116
+ }
117
+
118
+ export function buildPageSseFailureOutput({
119
+ error,
120
+ fallbackEndpoint,
121
+ fallbackMode = 'manual_after_diagnosis',
122
+ errorMessage
123
+ }) {
124
+ const diagnostics = readPageSseDiagnostics(error);
125
+ if (isPageSseScriptError(error)) {
126
+ return {
127
+ ok: false,
128
+ billable: false,
129
+ error: {
130
+ code: error.scriptCode,
131
+ message: errorMessage(error),
132
+ ...(diagnostics ? { diagnostics } : {})
133
+ },
134
+ routing: buildPageSseRouting(fallbackEndpoint, fallbackMode),
135
+ next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。'
136
+ };
137
+ }
138
+ if (isPageSseRequestRejected(error)) {
139
+ return {
140
+ ok: false,
141
+ billable: false,
142
+ error: {
143
+ code: 'page_sse_request_rejected',
144
+ status: error.status,
145
+ message: errorMessage(error),
146
+ ...(diagnostics ? { diagnostics } : {})
147
+ },
148
+ routing: buildPageSseRouting(fallbackEndpoint, 'fix_request_before_retry'),
149
+ next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
150
+ };
151
+ }
152
  return {
153
+ ok: false,
154
+ billable: true,
155
+ error: {
156
+ code: 'page_sse_failed',
157
+ ...buildPageSseFailureStatus(error),
158
+ message: errorMessage(error),
159
+ ...(diagnostics ? { diagnostics } : {})
160
+ },
161
+ routing: buildPageSseRouting(fallbackEndpoint, fallbackMode),
162
+ next_step:
163
+ '先用 diagnose-request 诊断页面流式失败原因,再用新的 Idempotency-Key 显式选择备用路径;若改用 Agent JSON/edit 对照,必须重新校验输出尺寸和格式。'
164
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  }
166
 
167
  export function normalizeImageBackendForPage(value) {
168
+ if (value === 'images') return 'images-api';
169
+ if (value === 'responses') return 'responses-image-generation';
170
+ return value;
171
  }
172
 
173
  export function isPageSseDisabledByStreamingOptions(value) {
174
+ return value?.streamMode === 'non_stream' || value?.streamingStrategy === 'off';
175
  }
176
 
177
  export function assertPageSseStreamingAllowed(value) {
178
+ if (isPageSseDisabledByStreamingOptions(value)) {
179
+ throw new Error('stream_mode=non_stream 或 streaming_strategy=off 时不能使用页面 SSE。');
180
+ }
181
  }
182
 
183
  function buildPageSseRouting(fallbackEndpoint, fallbackMode) {
184
+ return {
185
+ transport: 'page_sse',
186
+ endpoint: PAGE_SSE_ENDPOINT,
187
+ fallback_endpoint: fallbackEndpoint,
188
+ fallback_mode: fallbackMode
189
+ };
190
  }
191
 
192
  function createPageSseScriptError(code, message) {
193
+ const error = new Error(message);
194
+ error.scriptCode = code;
195
+ return error;
196
  }
197
 
198
  function isPageSseScriptError(error) {
199
+ return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string');
200
  }
201
 
202
  function isPageSseRequestRejected(error) {
203
+ return Boolean(
204
+ error &&
205
+ typeof error === 'object' &&
206
+ Number.isInteger(error.status) &&
207
+ error.status >= 400 &&
208
+ error.status < 500
209
+ );
210
  }
211
 
212
  function createPageSseHttpError(status, message) {
213
+ const error = new Error(formatErrorValue(message));
214
+ error.status = status;
215
+ return error;
216
  }
217
 
218
  function formatErrorValue(value) {
219
+ if (typeof value === 'string' && value.trim()) return value;
220
+ if (value && typeof value === 'object') {
221
+ if (typeof value.message === 'string' && value.message.trim()) return value.message;
222
+ if (typeof value.code === 'string' && value.code.trim()) return value.code;
223
+ try {
224
+ return JSON.stringify(value);
225
+ } catch {
226
+ return '页面 SSE 返回错误。';
227
+ }
228
  }
229
+ return '页面 SSE 返回错误。';
 
230
  }
231
 
232
  function readErrorFromJsonText(text) {
233
+ try {
234
+ const value = text ? JSON.parse(text) : null;
235
+ if (typeof value?.error === 'string') return value.error;
236
+ return value?.error || value;
237
+ } catch {
238
+ return undefined;
239
+ }
240
  }
241
 
242
  function parseJsonResponse(text, allowEmpty, url, errorMessage) {
243
+ if (!text && allowEmpty) return {};
244
+ try {
245
+ return JSON.parse(text);
246
+ } catch (error) {
247
+ throw new Error(`响应不是有效 JSON:${url}。${errorMessage(error)}`);
248
+ }
249
  }
250
 
251
  async function collectPageSseResult(response, signal, errorMessage, sseLogPath, trace = {}) {
252
+ const reader = response.body?.getReader();
253
+ if (!reader) throw new Error('页面 SSE 响应缺少 body。');
254
+ const decoder = new TextDecoder();
255
+ const state = createPageSseState();
256
+ let buffer = '';
257
+ while (true) {
258
+ const { done, value } = await readPageSseChunk(reader, signal);
259
+ if (done) break;
260
+ buffer += decoder.decode(value, { stream: true });
261
+ const events = buffer.split(/\r?\n\r?\n/);
262
+ buffer = events.pop() || '';
263
+ for (const rawEvent of events) {
264
+ appendPageSseLog(sseLogPath, rawEvent);
265
+ applyPageSseEvent(state, rawEvent, errorMessage);
266
+ }
267
  }
268
+ buffer += decoder.decode();
269
+ if (buffer.trim()) {
270
+ appendPageSseLog(sseLogPath, buffer);
271
+ applyPageSseEvent(state, buffer, errorMessage);
272
+ }
273
+ if (state.completedImages.length === 0) {
274
+ throw withPageSseDiagnostics(new Error('页面 SSE 未返回最终图片。'), state);
275
+ }
276
+ if (!state.doneReceived) {
277
+ throw withPageSseDiagnostics(new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'), state);
278
+ }
279
+ appendPageSseTrace(sseLogPath, 'request_completed', {
280
+ client_request_id: trace.clientRequestId,
281
+ endpoint: PAGE_SSE_ENDPOINT,
282
+ elapsed_ms: Date.now() - trace.startedAtMs,
283
+ final_image_count: state.completedImages.length
284
+ });
285
+ return {
286
+ images: state.completedImages,
287
+ usage: state.usage,
288
+ actualCost: state.actualCost,
289
+ sse_diagnostics: buildPageSseDiagnostics(state)
290
+ };
291
  }
292
 
293
  function appendPageSseLog(filePath, rawEvent) {
294
+ if (!filePath || !rawEvent.trim()) return;
295
+ try {
296
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
297
+ fs.appendFileSync(filePath, `${JSON.stringify({ at: new Date().toISOString(), raw_event: rawEvent })}\n`);
298
+ } catch (error) {
299
+ console.warn(`SSE log write failed: ${error instanceof Error ? error.message : String(error)}`);
300
+ }
301
  }
302
 
303
  function appendPageSseTrace(filePath, event, details) {
304
+ if (!filePath) return;
305
+ try {
306
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
307
+ fs.appendFileSync(filePath, `${JSON.stringify({ at: new Date().toISOString(), event, ...details })}\n`);
308
+ } catch (error) {
309
+ console.warn(`SSE log write failed: ${error instanceof Error ? error.message : String(error)}`);
310
+ }
311
  }
312
 
313
  function readFormDataString(formData, name) {
314
+ const value = formData?.get?.(name);
315
+ return typeof value === 'string' ? value : undefined;
316
  }
317
 
318
  function createPageSseState() {
319
+ return {
320
+ completedImages: [],
321
+ usage: undefined,
322
+ actualCost: undefined,
323
+ doneReceived: false,
324
+ completedEventCount: 0,
325
+ partialImageCount: 0,
326
+ lastEventType: undefined
327
+ };
328
  }
329
 
330
  function readPageSseChunk(reader, signal) {
331
+ if (!signal) return reader.read();
332
+ if (signal.aborted) return Promise.reject(new Error('请求超时。'));
333
+ return new Promise((resolve, reject) => {
334
+ const onAbort = () => reject(new Error('请求超时。'));
335
+ signal.addEventListener('abort', onAbort, { once: true });
336
+ reader
337
+ .read()
338
+ .then(resolve, reject)
339
+ .finally(() => {
340
+ signal.removeEventListener('abort', onAbort);
341
+ });
342
+ });
343
  }
344
 
345
  function applyPageSseEvent(state, rawEvent, errorMessage) {
346
+ const event = parsePageSseEvent(rawEvent, errorMessage);
347
+ if (!event) return;
348
+ const eventType = readPageSseEventType(event);
349
+ state.lastEventType = eventType;
350
+ if (isPartialPageSseEvent(event, eventType)) state.partialImageCount += 1;
351
+ if (event.type === 'error') {
352
+ throw createPageSseStreamError(event, state);
353
+ }
354
+ if (event.type === 'completed' && event.filename) {
355
+ state.completedEventCount += 1;
356
+ state.completedImages.push(
357
+ normalizePageSseImage(
358
+ {
359
+ filename: event.filename,
360
+ b64_json: event.b64_json,
361
+ path: event.path,
362
+ output_format: event.outputFormat || event.output_format
363
+ },
364
+ readPageSseClientRequestId(event)
365
+ )
366
+ );
367
+ return;
368
+ }
369
+ if (event.type === 'done') {
370
+ state.doneReceived = true;
371
+ const clientRequestId = readPageSseClientRequestId(event);
372
+ state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId);
373
+ state.usage = event.usage;
374
+ state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost;
375
+ }
376
  }
377
 
378
  function readPageSseEventType(event) {
379
+ if (typeof event.type === 'string' && event.type.trim()) return event.type;
380
+ if (typeof event.event === 'string' && event.event.trim()) return event.event;
381
+ return undefined;
382
  }
383
 
384
  function isPartialPageSseEvent(event, eventType) {
385
+ if (typeof eventType === 'string' && eventType.includes('partial_image')) return true;
386
+ return Boolean(event.partial_image || event.partialImage || event.partial_image_b64 || event.partialImageB64);
387
  }
388
 
389
  function parsePageSseEvent(rawEvent, errorMessage) {
390
+ const lines = rawEvent.split(/\r?\n/);
391
+ const data = lines
392
+ .filter((line) => line.startsWith('data: '))
393
+ .map((line) => line.slice(6))
394
+ .join('\n')
395
+ .trim();
396
+ if (!data || data === '[DONE]') return undefined;
397
+ try {
398
+ return JSON.parse(data);
399
+ } catch (error) {
400
+ throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`);
401
+ }
402
  }
403
 
404
  function createPageSseStreamError(event, state) {
405
+ const error = new Error(formatErrorValue(event.error));
406
+ const status = readPageSseStreamStatus(event);
407
+ if (Number.isInteger(status)) error.streamStatus = status;
408
+ return withPageSseDiagnostics(error, state);
409
  }
410
 
411
  function readPageSseStreamStatus(event) {
412
+ if (Number.isInteger(event.status)) return event.status;
413
+ if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) {
414
+ return event.error.status;
415
+ }
416
+ return undefined;
417
  }
418
 
419
  function readPageSseClientRequestId(event) {
420
+ if (typeof event.clientRequestId === 'string') return event.clientRequestId;
421
+ if (typeof event.client_request_id === 'string') return event.client_request_id;
422
+ return undefined;
423
  }
424
 
425
  function normalizePageSseImage(image, fallbackClientRequestId) {
426
+ const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId;
427
+ return {
428
+ ...image,
429
+ ...(image.output_format ? { output_format: image.output_format } : {}),
430
+ ...(clientRequestId ? { clientRequestId } : {})
431
+ };
432
  }
433
 
434
  function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
435
+ if (!Array.isArray(doneImages) || doneImages.length === 0) {
436
+ return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId));
437
+ }
438
+ const imageCount = Math.max(doneImages.length, completedImages.length);
439
+ return Array.from({ length: imageCount }, (_, index) =>
440
+ normalizePageSseImage(
441
+ { ...(completedImages[index] || {}), ...(doneImages[index] || {}) },
442
+ fallbackClientRequestId
443
+ )
444
+ );
445
+ }
446
+
447
+ function formatPageSseImage({ image, baseUrl, responseMode, defaultOutputFormat, preserveBase64 }) {
448
+ const output = {
449
+ ...image,
450
+ output_format: image.outputFormat || image.output_format || defaultOutputFormat
451
+ };
452
+ if (output.path) {
453
+ output.absolute_path = new URL(output.path, `${baseUrl}/`).toString();
454
+ output.content_url = output.content_url || output.path;
455
+ output.absolute_content_url = output.absolute_content_url || output.absolute_path;
456
+ if (!preserveBase64 && responseMode === 'path') delete output.b64_json;
457
+ }
458
+ return output;
459
  }
460
 
461
  function buildPageSseFailureStatus(error) {
462
+ if (error && typeof error === 'object') {
463
+ if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
464
+ if (Number.isInteger(error.status)) return { status: error.status };
465
+ }
466
+ return {};
467
  }
468
 
469
  function withPageSseDiagnostics(error, state) {
470
+ error.pageSseDiagnostics = buildPageSseDiagnostics(state);
471
+ return error;
472
  }
473
 
474
  function readPageSseDiagnostics(error) {
475
+ if (!error || typeof error !== 'object' || !error.pageSseDiagnostics) return undefined;
476
+ return error.pageSseDiagnostics;
477
  }
478
 
479
  function buildPageSseDiagnostics(state) {
480
+ return {
481
+ partial_image_count: state.partialImageCount,
482
+ completed_event_count: state.completedEventCount,
483
+ done_received: state.doneReceived,
484
+ final_image_count: state.completedImages.length,
485
+ ...(state.lastEventType ? { last_upstream_event_type: state.lastEventType } : {})
486
+ };
487
  }
skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs CHANGED
@@ -1,244 +1,261 @@
1
  export function startScriptTiming(now = Date.now()) {
2
- return {
3
- startedAtMs: now,
4
- startedAt: new Date(now).toISOString()
5
- };
6
  }
7
 
8
  export function completeScriptTiming(started, now = Date.now()) {
9
- return {
10
- started_at: started.startedAt,
11
- completed_at: new Date(now).toISOString(),
12
- elapsed_ms: Math.max(0, now - started.startedAtMs)
13
- };
14
  }
15
 
16
  export function buildSuccessSummary({ result, routing, timing, idempotencyKey, billable = true, nextAction }) {
17
- const serverTiming = readObject(result?.timing);
18
- const execution = readObject(result?.execution);
19
- const images = Array.isArray(result?.images) ? result.images : [];
20
- const imageDimensions = readResponseImageDimensions(images);
21
- const timingSummary = buildTimingSummary({ clientTiming: timing, serverTiming });
22
- return stableSummary({
23
- ok: true,
24
- billable,
25
- request_id: readString(result?.request_id) || null,
26
- idempotency_key: readString(result?.idempotency_key) || idempotencyKey,
27
- artifact_ids: images.map((image) => image?.id).filter((value) => typeof value === 'string' && value),
28
- content_urls: readImageUrls(images, ['content_url', 'path']),
29
- absolute_content_urls: readImageUrls(images, ['absolute_content_url', 'absolute_path']),
30
- image_dimensions: imageDimensions,
31
- actual_dimensions: imageDimensions.length === 1 ? imageDimensions[0] : null,
32
- cached: typeof result?.cached === 'boolean' ? result.cached : undefined,
33
- started_at: timingSummary.started_at,
34
- completed_at: timingSummary.completed_at,
35
- elapsed_ms: timingSummary.elapsed_ms,
36
- server_elapsed_ms: timingSummary.server_elapsed_ms,
37
- elapsed_source: timingSummary.elapsed_source,
38
- elapsed_breakdown: timingSummary.elapsed_breakdown,
39
- transport: readString(execution?.transport) || readString(routing?.transport),
40
- endpoint: readString(execution?.endpoint) || readString(routing?.endpoint),
41
- route_mode: readString(execution?.route_mode) || readRouteMode(routing),
42
- image_backend: readString(execution?.image_backend) || readString(routing?.image_backend) || null,
43
- stream_mode: readString(execution?.stream_mode) || readString(routing?.stream_mode) || null,
44
- streaming_strategy: readString(execution?.streaming_strategy) || readString(routing?.streaming_strategy) || null,
45
- channel_request_mode: readString(execution?.channel_request_mode) || null,
46
- channel_request_mode_fallback_applied:
47
- typeof execution?.channel_request_mode_fallback_applied === 'boolean'
48
- ? execution.channel_request_mode_fallback_applied
49
- : null,
50
- route_decision: readObject(execution?.route_decision) || null,
51
- selected_channel_id: readString(execution?.selected_channel_id) || null,
52
- upstream_host: readString(execution?.upstream_host) || null,
53
- request_headers: readObject(execution?.request_headers),
54
- retryable: false,
55
- next_action: nextAction || 'done'
56
- });
 
57
  }
58
 
59
  export function buildFailureSummary({ errorBody, routing, timing, idempotencyKey, billable, nextAction }) {
60
- const error = readObject(errorBody?.error) || readObject(errorBody);
61
- const errorMessage = readString(error?.message) || readString(errorBody?.error);
62
- const diagnostics = readObject(error?.diagnostics);
63
- const response = readObject(errorBody?.response);
64
- const images = Array.isArray(response?.images) ? response.images : [];
65
- const imageDimensions = readResponseImageDimensions(images);
66
- const timingSummary = buildTimingSummary({ clientTiming: timing, diagnostics });
67
- return stableSummary({
68
- ok: false,
69
- billable,
70
- request_id: readString(error?.request_id) || null,
71
- idempotency_key: idempotencyKey,
72
- artifact_ids: images.map((image) => image?.id).filter((value) => typeof value === 'string' && value),
73
- content_urls: readImageUrls(images, ['content_url', 'path']),
74
- absolute_content_urls: readImageUrls(images, ['absolute_content_url', 'absolute_path']),
75
- image_dimensions: imageDimensions,
76
- expected_dimensions: readDimensionObject(error?.expected_dimensions),
77
- actual_dimensions: readDimensionObject(error?.actual_dimensions),
78
- dimension_check_failed: error?.code === 'dimension_check_failed' ? true : undefined,
79
- started_at: timingSummary.started_at,
80
- completed_at: timingSummary.completed_at,
81
- elapsed_ms: timingSummary.elapsed_ms,
82
- server_elapsed_ms: timingSummary.server_elapsed_ms,
83
- elapsed_source: timingSummary.elapsed_source,
84
- elapsed_breakdown: timingSummary.elapsed_breakdown,
85
- transport: readString(routing?.transport),
86
- endpoint: readString(routing?.endpoint),
87
- route_mode: readRouteMode(routing),
88
- image_backend: readString(routing?.image_backend) || null,
89
- stream_mode: readString(routing?.stream_mode) || null,
90
- streaming_strategy: readString(routing?.streaming_strategy) || null,
91
- channel_request_mode: readString(diagnostics?.channel_request_mode) || null,
92
- channel_request_mode_fallback_applied:
93
- typeof diagnostics?.channel_request_mode_fallback_applied === 'boolean'
94
- ? diagnostics.channel_request_mode_fallback_applied
95
- : null,
96
- route_decision: readObject(diagnostics?.route_decision) || null,
97
- selected_channel_id: readString(diagnostics?.selected_channel_id) || null,
98
- upstream_host: readString(diagnostics?.upstream_host) || null,
99
- transport_error_kind: readString(diagnostics?.transport_error_kind),
100
- retryable: typeof error?.retryable === 'boolean' ? error.retryable : undefined,
101
- retry_after_seconds: readNonNegativeNumber(errorBody?.retry_after ?? diagnostics?.retry_after_seconds),
102
- retry_after_ms: readNonNegativeNumber(diagnostics?.retry_after_ms),
103
- cooldown_until: readString(diagnostics?.cooldown_until),
104
- cooldown_target: readObject(diagnostics?.cooldown_target),
105
- next_action: nextAction || buildFailureNextAction(error),
106
- error_code: readString(error?.code),
107
- error_message: errorMessage
108
- });
 
 
 
 
 
 
 
109
  }
110
 
111
  export function attachSummary(output, summary) {
112
- return summary ? { ...output, summary } : output;
113
  }
114
 
115
  function buildFailureNextAction(error) {
116
- if (!error) return 'diagnose';
117
- if (error.retryable === true) return 'retry_after_wait';
118
- return 'diagnose_then_new_idempotency_key';
119
  }
120
 
121
  function stableSummary(value) {
122
- return {
123
- ...value,
124
- request_id: value.request_id ?? null,
125
- idempotency_key: value.idempotency_key ?? null,
126
- artifact_ids: Array.isArray(value.artifact_ids) ? value.artifact_ids : [],
127
- content_urls: Array.isArray(value.content_urls) ? value.content_urls : [],
128
- absolute_content_urls: Array.isArray(value.absolute_content_urls) ? value.absolute_content_urls : [],
129
- image_dimensions: Array.isArray(value.image_dimensions) ? value.image_dimensions : [],
130
- expected_dimensions: value.expected_dimensions ?? null,
131
- actual_dimensions: value.actual_dimensions ?? null,
132
- dimension_check_failed: value.dimension_check_failed ?? false,
133
- route_mode: value.route_mode ?? null,
134
- image_backend: value.image_backend ?? null,
135
- stream_mode: value.stream_mode ?? null,
136
- streaming_strategy: value.streaming_strategy ?? null,
137
- channel_request_mode: value.channel_request_mode ?? null,
138
- channel_request_mode_fallback_applied: value.channel_request_mode_fallback_applied ?? null,
139
- route_decision: value.route_decision ?? null,
140
- selected_channel_id: value.selected_channel_id ?? null,
141
- upstream_host: value.upstream_host ?? null,
142
- transport_error_kind: value.transport_error_kind ?? null,
143
- retry_after_ms: value.retry_after_ms ?? null,
144
- retry_after_seconds: value.retry_after_seconds ?? null,
145
- cooldown_until: value.cooldown_until ?? null,
146
- cooldown_target: value.cooldown_target ?? null,
147
- agent_diagnostics_checked: value.agent_diagnostics_checked ?? false,
148
- agent_diagnostics_found: value.agent_diagnostics_found ?? false,
149
- agent_diagnostics_unavailable_reason: value.agent_diagnostics_unavailable_reason ?? null,
150
- agent_diagnostics_http_status: value.agent_diagnostics_http_status ?? null
151
- };
152
  }
153
 
154
  function compactObject(value) {
155
- return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
156
  }
157
 
158
  function readObject(value) {
159
- return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
160
  }
161
 
162
  function readString(value) {
163
- return typeof value === 'string' && value ? value : undefined;
164
  }
165
 
166
  function readNonNegativeNumber(value) {
167
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
168
- return Math.round(value);
169
  }
170
 
171
  function readRouteMode(routing) {
172
- if (typeof routing?.route_mode === 'string' && routing.route_mode) return routing.route_mode;
173
- if (routing?.transport === 'page_sse') return 'page_sse';
174
- if (routing?.transport === 'server_orchestrated') return 'orchestrated';
175
- if (routing?.transport === 'agent_job_polling') return 'job';
176
- if (routing?.transport === 'agent_json') return 'agent';
177
- return undefined;
178
  }
179
 
180
  function readImageUrls(images, fields) {
181
- return images
182
- .map((image) => {
183
- for (const field of fields) {
184
- if (typeof image?.[field] === 'string' && image[field]) return image[field];
185
- }
186
- return undefined;
187
- })
188
- .filter((value) => typeof value === 'string' && value);
189
  }
190
 
191
  function readResponseImageDimensions(images) {
192
- return images
193
- .map((image) => readDimensionObject(image?.dimensions) || readDimensionObject(image?.metadata?.dimensions) || readImageTopLevelDimensions(image))
194
- .filter((value) => value !== undefined);
 
 
 
 
 
195
  }
196
 
197
  function readImageTopLevelDimensions(image) {
198
- if (!readObject(image)) return undefined;
199
- if (!Object.prototype.hasOwnProperty.call(image, 'width')) return undefined;
200
- if (!Object.prototype.hasOwnProperty.call(image, 'height')) return undefined;
201
- return readDimensionObject(image);
202
  }
203
 
204
  function readDimensionObject(value) {
205
- if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
206
- const width = readPositiveInteger(value.width);
207
- const height = readPositiveInteger(value.height);
208
- if (width === undefined || height === undefined) return undefined;
209
- return { width, height };
210
  }
211
 
212
  function readPositiveInteger(value) {
213
- if (!Number.isInteger(value) || value <= 0) return undefined;
214
- return value;
215
  }
216
 
217
  function buildTimingSummary({ clientTiming, serverTiming, diagnostics }) {
218
- const serverTimingElapsedMs = readNonNegativeNumber(serverTiming?.elapsed_ms);
219
- const diagnosticsElapsedMs = readNonNegativeNumber(diagnostics?.elapsed_ms);
220
- const serverElapsedMs = readNonNegativeNumber(serverTiming?.server_elapsed_ms ?? serverTimingElapsedMs ?? diagnosticsElapsedMs);
221
- const clientElapsedMs = readNonNegativeNumber(clientTiming?.elapsed_ms);
222
- const elapsedMs = serverTimingElapsedMs ?? diagnosticsElapsedMs ?? clientElapsedMs;
223
- const elapsedSource =
224
- serverTimingElapsedMs !== undefined || diagnosticsElapsedMs !== undefined ? 'server_timing' : 'client_script';
225
- const summary = compactObject({
226
- started_at: readString(serverTiming?.started_at) || clientTiming?.started_at,
227
- completed_at: readString(serverTiming?.completed_at) || clientTiming?.completed_at,
228
- elapsed_ms: elapsedMs,
229
- server_elapsed_ms: serverElapsedMs,
230
- elapsed_source: elapsedSource,
231
- elapsed_breakdown: buildElapsedBreakdown(clientElapsedMs, serverElapsedMs)
232
- });
233
- return summary;
 
 
234
  }
235
 
236
  function buildElapsedBreakdown(clientElapsedMs, serverElapsedMs) {
237
- if (clientElapsedMs === undefined && serverElapsedMs === undefined) return undefined;
238
- return compactObject({
239
- client_script_ms: clientElapsedMs,
240
- upstream_or_server_ms: serverElapsedMs,
241
- client_overhead_ms:
242
- clientElapsedMs !== undefined && serverElapsedMs !== undefined ? Math.max(0, clientElapsedMs - serverElapsedMs) : undefined
243
- });
 
 
244
  }
 
1
  export function startScriptTiming(now = Date.now()) {
2
+ return {
3
+ startedAtMs: now,
4
+ startedAt: new Date(now).toISOString()
5
+ };
6
  }
7
 
8
  export function completeScriptTiming(started, now = Date.now()) {
9
+ return {
10
+ started_at: started.startedAt,
11
+ completed_at: new Date(now).toISOString(),
12
+ elapsed_ms: Math.max(0, now - started.startedAtMs)
13
+ };
14
  }
15
 
16
  export function buildSuccessSummary({ result, routing, timing, idempotencyKey, billable = true, nextAction }) {
17
+ const serverTiming = readObject(result?.timing);
18
+ const execution = readObject(result?.execution);
19
+ const images = Array.isArray(result?.images) ? result.images : [];
20
+ const imageDimensions = readResponseImageDimensions(images);
21
+ const timingSummary = buildTimingSummary({ clientTiming: timing, serverTiming });
22
+ return stableSummary({
23
+ ok: true,
24
+ billable,
25
+ request_id: readString(result?.request_id) || null,
26
+ idempotency_key: readString(result?.idempotency_key) || idempotencyKey,
27
+ artifact_ids: images.map((image) => image?.id).filter((value) => typeof value === 'string' && value),
28
+ content_urls: readImageUrls(images, ['content_url', 'path']),
29
+ absolute_content_urls: readImageUrls(images, ['absolute_content_url', 'absolute_path']),
30
+ image_dimensions: imageDimensions,
31
+ actual_dimensions: imageDimensions.length === 1 ? imageDimensions[0] : null,
32
+ cached: typeof result?.cached === 'boolean' ? result.cached : undefined,
33
+ started_at: timingSummary.started_at,
34
+ completed_at: timingSummary.completed_at,
35
+ elapsed_ms: timingSummary.elapsed_ms,
36
+ server_elapsed_ms: timingSummary.server_elapsed_ms,
37
+ elapsed_source: timingSummary.elapsed_source,
38
+ elapsed_breakdown: timingSummary.elapsed_breakdown,
39
+ transport: readString(execution?.transport) || readString(routing?.transport),
40
+ endpoint: readString(execution?.endpoint) || readString(routing?.endpoint),
41
+ route_mode: readString(execution?.route_mode) || readRouteMode(routing),
42
+ image_backend: readString(execution?.image_backend) || readString(routing?.image_backend) || null,
43
+ stream_mode: readString(execution?.stream_mode) || readString(routing?.stream_mode) || null,
44
+ streaming_strategy:
45
+ readString(execution?.streaming_strategy) || readString(routing?.streaming_strategy) || null,
46
+ channel_request_mode: readString(execution?.channel_request_mode) || null,
47
+ channel_request_mode_fallback_applied:
48
+ typeof execution?.channel_request_mode_fallback_applied === 'boolean'
49
+ ? execution.channel_request_mode_fallback_applied
50
+ : null,
51
+ route_decision: readObject(execution?.route_decision) || null,
52
+ selected_channel_id: readString(execution?.selected_channel_id) || null,
53
+ upstream_host: readString(execution?.upstream_host) || null,
54
+ request_headers: readObject(execution?.request_headers),
55
+ retryable: false,
56
+ next_action: nextAction || 'done'
57
+ });
58
  }
59
 
60
  export function buildFailureSummary({ errorBody, routing, timing, idempotencyKey, billable, nextAction }) {
61
+ const error = readObject(errorBody?.error) || readObject(errorBody);
62
+ const errorMessage = readString(error?.message) || readString(errorBody?.error);
63
+ const diagnostics = readObject(error?.diagnostics);
64
+ const response = readObject(errorBody?.response);
65
+ const execution = readObject(response?.execution);
66
+ const images = Array.isArray(response?.images) ? response.images : [];
67
+ const imageDimensions = readResponseImageDimensions(images);
68
+ const timingSummary = buildTimingSummary({ clientTiming: timing, diagnostics });
69
+ // Failure bodies may put final routing diagnostics on error.diagnostics even when response.execution is absent.
70
+ return stableSummary({
71
+ ok: false,
72
+ billable,
73
+ request_id: readString(error?.request_id) || null,
74
+ idempotency_key: idempotencyKey,
75
+ artifact_ids: images.map((image) => image?.id).filter((value) => typeof value === 'string' && value),
76
+ content_urls: readImageUrls(images, ['content_url', 'path']),
77
+ absolute_content_urls: readImageUrls(images, ['absolute_content_url', 'absolute_path']),
78
+ image_dimensions: imageDimensions,
79
+ expected_dimensions: readDimensionObject(error?.expected_dimensions),
80
+ actual_dimensions: readDimensionObject(error?.actual_dimensions),
81
+ dimension_check_failed: error?.code === 'dimension_check_failed' ? true : undefined,
82
+ started_at: timingSummary.started_at,
83
+ completed_at: timingSummary.completed_at,
84
+ elapsed_ms: timingSummary.elapsed_ms,
85
+ server_elapsed_ms: timingSummary.server_elapsed_ms,
86
+ elapsed_source: timingSummary.elapsed_source,
87
+ elapsed_breakdown: timingSummary.elapsed_breakdown,
88
+ transport: readString(execution?.transport) || readString(routing?.transport),
89
+ endpoint: readString(execution?.endpoint) || readString(routing?.endpoint),
90
+ route_mode: readString(execution?.route_mode) || readRouteMode(routing),
91
+ image_backend: readString(execution?.image_backend) || readString(routing?.image_backend) || null,
92
+ stream_mode: readString(execution?.stream_mode) || readString(routing?.stream_mode) || null,
93
+ streaming_strategy:
94
+ readString(execution?.streaming_strategy) || readString(routing?.streaming_strategy) || null,
95
+ channel_request_mode:
96
+ readString(diagnostics?.channel_request_mode) || readString(execution?.channel_request_mode) || null,
97
+ channel_request_mode_fallback_applied:
98
+ typeof diagnostics?.channel_request_mode_fallback_applied === 'boolean'
99
+ ? diagnostics.channel_request_mode_fallback_applied
100
+ : typeof execution?.channel_request_mode_fallback_applied === 'boolean'
101
+ ? execution.channel_request_mode_fallback_applied
102
+ : null,
103
+ route_decision: readObject(diagnostics?.route_decision) || readObject(execution?.route_decision) || null,
104
+ selected_channel_id:
105
+ readString(diagnostics?.selected_channel_id) || readString(execution?.selected_channel_id) || null,
106
+ upstream_host: readString(diagnostics?.upstream_host) || readString(execution?.upstream_host) || null,
107
+ transport_error_kind: readString(diagnostics?.transport_error_kind),
108
+ retryable: typeof error?.retryable === 'boolean' ? error.retryable : undefined,
109
+ retry_after_seconds: readNonNegativeNumber(errorBody?.retry_after ?? diagnostics?.retry_after_seconds),
110
+ retry_after_ms: readNonNegativeNumber(diagnostics?.retry_after_ms),
111
+ cooldown_until: readString(diagnostics?.cooldown_until),
112
+ cooldown_target: readObject(diagnostics?.cooldown_target),
113
+ next_action: nextAction || buildFailureNextAction(error),
114
+ error_code: readString(error?.code),
115
+ error_message: errorMessage
116
+ });
117
  }
118
 
119
  export function attachSummary(output, summary) {
120
+ return summary ? { ...output, summary } : output;
121
  }
122
 
123
  function buildFailureNextAction(error) {
124
+ if (!error) return 'diagnose';
125
+ if (error.retryable === true) return 'retry_after_wait';
126
+ return 'diagnose_then_new_idempotency_key';
127
  }
128
 
129
  function stableSummary(value) {
130
+ return {
131
+ ...value,
132
+ request_id: value.request_id ?? null,
133
+ idempotency_key: value.idempotency_key ?? null,
134
+ artifact_ids: Array.isArray(value.artifact_ids) ? value.artifact_ids : [],
135
+ content_urls: Array.isArray(value.content_urls) ? value.content_urls : [],
136
+ absolute_content_urls: Array.isArray(value.absolute_content_urls) ? value.absolute_content_urls : [],
137
+ image_dimensions: Array.isArray(value.image_dimensions) ? value.image_dimensions : [],
138
+ expected_dimensions: value.expected_dimensions ?? null,
139
+ actual_dimensions: value.actual_dimensions ?? null,
140
+ dimension_check_failed: value.dimension_check_failed ?? false,
141
+ route_mode: value.route_mode ?? null,
142
+ image_backend: value.image_backend ?? null,
143
+ stream_mode: value.stream_mode ?? null,
144
+ streaming_strategy: value.streaming_strategy ?? null,
145
+ channel_request_mode: value.channel_request_mode ?? null,
146
+ channel_request_mode_fallback_applied: value.channel_request_mode_fallback_applied ?? null,
147
+ route_decision: value.route_decision ?? null,
148
+ selected_channel_id: value.selected_channel_id ?? null,
149
+ upstream_host: value.upstream_host ?? null,
150
+ transport_error_kind: value.transport_error_kind ?? null,
151
+ retry_after_ms: value.retry_after_ms ?? null,
152
+ retry_after_seconds: value.retry_after_seconds ?? null,
153
+ cooldown_until: value.cooldown_until ?? null,
154
+ cooldown_target: value.cooldown_target ?? null,
155
+ agent_diagnostics_checked: value.agent_diagnostics_checked ?? false,
156
+ agent_diagnostics_found: value.agent_diagnostics_found ?? false,
157
+ agent_diagnostics_unavailable_reason: value.agent_diagnostics_unavailable_reason ?? null,
158
+ agent_diagnostics_http_status: value.agent_diagnostics_http_status ?? null
159
+ };
160
  }
161
 
162
  function compactObject(value) {
163
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
164
  }
165
 
166
  function readObject(value) {
167
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
168
  }
169
 
170
  function readString(value) {
171
+ return typeof value === 'string' && value ? value : undefined;
172
  }
173
 
174
  function readNonNegativeNumber(value) {
175
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) return undefined;
176
+ return Math.round(value);
177
  }
178
 
179
  function readRouteMode(routing) {
180
+ if (typeof routing?.route_mode === 'string' && routing.route_mode) return routing.route_mode;
181
+ if (routing?.transport === 'page_sse') return 'page_sse';
182
+ if (routing?.transport === 'server_orchestrated') return 'orchestrated';
183
+ if (routing?.transport === 'agent_job_polling') return 'job';
184
+ if (routing?.transport === 'agent_json') return 'agent';
185
+ return undefined;
186
  }
187
 
188
  function readImageUrls(images, fields) {
189
+ return images
190
+ .map((image) => {
191
+ for (const field of fields) {
192
+ if (typeof image?.[field] === 'string' && image[field]) return image[field];
193
+ }
194
+ return undefined;
195
+ })
196
+ .filter((value) => typeof value === 'string' && value);
197
  }
198
 
199
  function readResponseImageDimensions(images) {
200
+ return images
201
+ .map(
202
+ (image) =>
203
+ readDimensionObject(image?.dimensions) ||
204
+ readDimensionObject(image?.metadata?.dimensions) ||
205
+ readImageTopLevelDimensions(image)
206
+ )
207
+ .filter((value) => value !== undefined);
208
  }
209
 
210
  function readImageTopLevelDimensions(image) {
211
+ if (!readObject(image)) return undefined;
212
+ if (!Object.prototype.hasOwnProperty.call(image, 'width')) return undefined;
213
+ if (!Object.prototype.hasOwnProperty.call(image, 'height')) return undefined;
214
+ return readDimensionObject(image);
215
  }
216
 
217
  function readDimensionObject(value) {
218
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
219
+ const width = readPositiveInteger(value.width);
220
+ const height = readPositiveInteger(value.height);
221
+ if (width === undefined || height === undefined) return undefined;
222
+ return { width, height };
223
  }
224
 
225
  function readPositiveInteger(value) {
226
+ if (!Number.isInteger(value) || value <= 0) return undefined;
227
+ return value;
228
  }
229
 
230
  function buildTimingSummary({ clientTiming, serverTiming, diagnostics }) {
231
+ const serverTimingElapsedMs = readNonNegativeNumber(serverTiming?.elapsed_ms);
232
+ const diagnosticsElapsedMs = readNonNegativeNumber(diagnostics?.elapsed_ms);
233
+ const serverElapsedMs = readNonNegativeNumber(
234
+ serverTiming?.server_elapsed_ms ?? serverTimingElapsedMs ?? diagnosticsElapsedMs
235
+ );
236
+ const clientElapsedMs = readNonNegativeNumber(clientTiming?.elapsed_ms);
237
+ const elapsedMs = serverTimingElapsedMs ?? diagnosticsElapsedMs ?? clientElapsedMs;
238
+ const elapsedSource =
239
+ serverTimingElapsedMs !== undefined || diagnosticsElapsedMs !== undefined ? 'server_timing' : 'client_script';
240
+ const summary = compactObject({
241
+ started_at: readString(serverTiming?.started_at) || clientTiming?.started_at,
242
+ completed_at: readString(serverTiming?.completed_at) || clientTiming?.completed_at,
243
+ elapsed_ms: elapsedMs,
244
+ server_elapsed_ms: serverElapsedMs,
245
+ elapsed_source: elapsedSource,
246
+ elapsed_breakdown: buildElapsedBreakdown(clientElapsedMs, serverElapsedMs)
247
+ });
248
+ return summary;
249
  }
250
 
251
  function buildElapsedBreakdown(clientElapsedMs, serverElapsedMs) {
252
+ if (clientElapsedMs === undefined && serverElapsedMs === undefined) return undefined;
253
+ return compactObject({
254
+ client_script_ms: clientElapsedMs,
255
+ upstream_or_server_ms: serverElapsedMs,
256
+ client_overhead_ms:
257
+ clientElapsedMs !== undefined && serverElapsedMs !== undefined
258
+ ? Math.max(0, clientElapsedMs - serverElapsedMs)
259
+ : undefined
260
+ });
261
  }
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":"b908b02b1ca43bf88871a9ffb18a6bc9421263ad","created_at":"2026-07-03T18:03:10.848Z","deploy_id":"3662dde9-7453-4db4-872d-b86510c45633"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
 
1
  import { NextResponse } from 'next/server';
2
 
3
+ const deployMarker = {"schema_version":1,"local_sha":"e9050df6c33f04cecaad6d1fcd2c7714a262bb12","created_at":"2026-07-06T10:09:57.902Z","deploy_id":"572b33fc-af8f-417f-bc6f-70cf33b54c06"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6