misonL commited on
Commit
754f2d6
·
verified ·
1 Parent(s): d1b58b0

Deploy 3a8b0c7 to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@3a8b0c75f35f7fb9ad0e6b81cae55bd49ca2572a

.env.example CHANGED
@@ -96,9 +96,13 @@ OPENAI_API_BASE_URL=
96
  # 要使用 Responses 后端,需要请求显式传入 image_backend=responses-image-generation,
97
  # 或把 IMAGE_GENERATION_BACKEND 设为 responses-image-generation / responses;
98
  # 两种方式都需要单独配置 /responses 顶层模型。
 
 
 
 
99
  # 表单 model 只作为 image_generation 工具模型,不会复用为 /responses 顶层模型。
100
  # ENABLE_RESPONSES_IMAGE_BACKEND=true
101
- # OPENAI_RESPONSES_API_MODEL=
102
 
103
  # 可选:独立真实上游 smoke 目标。默认不触发计费请求,必须显式运行 --allow-billable。
104
  # 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。
 
96
  # 要使用 Responses 后端,需要请求显式传入 image_backend=responses-image-generation,
97
  # 或把 IMAGE_GENERATION_BACKEND 设为 responses-image-generation / responses;
98
  # 两种方式都需要单独配置 /responses 顶层模型。
99
+ # OPENAI_RESPONSES_API_MODEL 是服务端默认顶层模型,页面请求可用 responsesModel、
100
+ # responses_model、gptModel 或 gpt_model 覆盖单次请求。
101
+ # 该配置只影响本项目的 responses-image-generation 路径,不改变兼容上游自身
102
+ # /v1/images/generations 桥接层内部选择的模型。
103
  # 表单 model 只作为 image_generation 工具模型,不会复用为 /responses 顶层模型。
104
  # ENABLE_RESPONSES_IMAGE_BACKEND=true
105
+ # OPENAI_RESPONSES_API_MODEL=gpt-5.4
106
 
107
  # 可选:独立真实上游 smoke 目标。默认不触发计费请求,必须显式运行 --allow-billable。
108
  # 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。
README.md CHANGED
@@ -17,27 +17,20 @@ app_port: 4783
17
 
18
  ## 快速开始
19
 
20
- 第一次配置或换机器后,先跑只读就绪检查。它不会写配置、不会输出密钥、不会触发真实生图:
21
 
22
  ```bash
23
  npm run first-run
24
  ```
25
 
26
- 该命令默认输出中文摘要;给 Agent 或脚本消费时加 `--json`。它会检查 Node、依赖、`.env.local` / `.env.agent.local` 摘要、默认本地服务 `http://localhost:4783`、Agent capabilities 和下一步动作。检查公网或 Space 服务时显式传地址:
27
 
28
  ```bash
29
  npm run first-run -- --base-url https://your-space.hf.space
30
  npm run first-run -- --json --base-url https://your-space.hf.space
31
  ```
32
 
33
- 首次配置最短路径
34
-
35
- 1. 运行 `npm install`。
36
- 2. 启动服务:本地开发用 `npm run dev`,Docker 用 `docker compose up -d --build --remove-orphans`。
37
- 3. 运行 `npm run first-run` 看中文摘要;如果要检查 Space 或内网服务,使用 `npm run first-run -- --base-url <url>`。
38
- 4. 如果 Agent API 需要鉴权,复制 `.env.agent.local.example` 为 `.env.agent.local` 并填入本机私有 token。Agent CLI 会从当前仓库根目录自动读取该文件;shell 环境变量仍然优先。
39
-
40
- 推荐 Docker:
41
 
42
  ```bash
43
  docker compose up -d --build --remove-orphans
@@ -56,7 +49,7 @@ OPENAI_API_KEY=your_openai_api_key_here
56
  OPENAI_API_BASE_URL=https://api.openai.com/v1
57
  ```
58
 
59
- 本地开发:
60
 
61
  ```bash
62
  npm install
@@ -106,6 +99,7 @@ start-windows.bat
106
  | 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | `fs` 或 `indexeddb`。Docker 默认使用 `fs`。 |
107
  | Agent 状态 | `AGENT_STATE_BACKEND` | `memory`、`sqlite` 或 `postgres`。Docker 默认使用 `sqlite`。 |
108
  | 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
 
109
  | 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
110
  | 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
111
  | 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
@@ -142,18 +136,20 @@ OPENAI_CHANNEL_3_UPSTREAM_PROFILE=matsca
142
 
143
  - 自定义 API URL 必须同时填写自定义 API Key,避免服务端密钥被发送到未知地址。
144
  - Docker compose 本身不把默认图片后端改成 Responses;未在 `.env.local` 显式配置时仍是 `images-api` 和 `auto`。
145
- - Responses image backend 需要 `ENABLE_RESPONSES_IMAGE_BACKEND=true` 和 `OPENAI_RESPONSES_API_MODEL`。
146
  - Matsca、extra headers、provider manifest、真实上游 smoke 等高级配置以 [.env.example](./.env.example) 为准。
147
 
148
  ## Agent API
149
 
150
- Agent API 是机器接口,不是自治 Agent 平台。自动化客户端应先读取 capabilities,再按返回的认证方式、路由规则、状态后端和端点能力选择路径。
 
 
151
 
152
  常用入口:
153
 
154
  | 接口 | 用途 |
155
  | --- | --- |
156
- | `GET /api/agent/capabilities` | 查询模型、限制、认证方式、状态后端和路由规则。 |
157
  | `GET /api/agent/openapi.json` | 获取 OpenAPI 描述。 |
158
  | `POST /api/agent/images/generate` | JSON 文生图。 |
159
  | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 mask。 |
@@ -162,7 +158,7 @@ Agent API 是机器接口,不是自治 Agent 平台。自动化客户端应先
162
  | `GET /api/agent/jobs/{id}/result` | 读取成功 job 的标准图片响应。 |
163
  | `GET /api/agent/artifacts/{id}/content` | 下载产物图片。 |
164
  | `POST /api/agent/diagnostics/page-requests` | 批量读取页面请求的脱敏日志诊断摘要。 |
165
- | `GET /api/agent/diagnostics/requests` | 按 Agent request id 或幂等键查询 Agent state 请求诊断。 |
166
 
167
  生成示例:
168
 
@@ -179,7 +175,7 @@ curl -s http://localhost:4783/api/agent/images/generate \
179
  - [skills/gpt-image-playground-agent/SKILL.md](./skills/gpt-image-playground-agent/SKILL.md)
180
  - [Agent API 参考](./skills/gpt-image-playground-agent/references/api.md)
181
 
182
- 脚本默认 dry-run,不触发真实计费请求;真实生成必须显式添加 `--allow-billable`
183
 
184
  ```bash
185
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
@@ -190,17 +186,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
190
 
191
  ### AI Agent 典型使用方式
192
 
193
- AI Agent 集成时优先调用 skill 内置脚本,临时手写 fetch、curl 或表单提交逻辑。脚本会先读取 capabilities,自动处理鉴权、幂等键、路由选择、超时、产物 URL 和结构化失败摘要。
194
-
195
- 交互式任务中,Agent 应先定位服务地址:用户明确提供 URL 时直接使用该 URL;否则先检查 `GPT_IMAGE_PLAYGROUND_URL`,再探测默认本地地址 `http://localhost:4783`。如果只发现环境变量或本地服务,先向用户确认是否使用;用户提供其他地址时,以用户提供的地址为准。非交互式任务无法确认时,按同一顺序自动选择,并在输出里说明地址来源。
196
-
197
- 新环境或不确定服务地址时先运行 `npm run first-run`。它会只读报告 `service_base_url_source`、`interactive_confirmation_required`、服务可达性、当前进程是否拿到 Agent 鉴权,以及 `.env.agent.local` 是否存在私有鉴权配置。Agent CLI 会从当前仓库根目录自动读取 `.env.agent.local`,shell 环境变量仍然优先;如需禁用自动读取,设置 `GPT_IMAGE_AGENT_LOAD_ENV_FILE=0`。
198
-
199
- dry-run 只做本地请求构造和静态路由规划,不读取远端 capabilities,也不验证远端鉴权、渠道容量或 manifest 写入。脚本输出里的 `verification_scope.mode=local_planning_only` 表示还没有证明远端服务可执行;需要远端合同检查时使用 `--contract-check`,真实执行必须显式添加 `--allow-billable`。
200
-
201
- subagent 或自动化任务要固定服务地址时,优先给脚本传 `--base-url`,不要只依赖默认 localhost。`generate-image.mjs`、`edit-image.mjs`、`batch-images.mjs`、`diagnose-request.mjs` 和 `npm run agent:doctor -- --base-url <url>` 都支持显式服务地址。首次配置 Agent 鉴权时复制 `.env.agent.local.example` 为 `.env.agent.local` 并填入本机私有 token;不要把 `.env.agent.local` 提交或粘到任务日志。
202
-
203
- 公网部署常见有两层鉴权:`GPT_IMAGE_AGENT_TOKEN` 只用于 `/api/agent/*` Bearer 鉴权;页面 SSE `/api/images` 仍可能要求 `GPT_IMAGE_APP_PASSWORD_HASH` 作为 `passwordHash` 表单字段。使用 `--page-sse`、Responses backend edit、大图默认页面 SSE 或批量页面 SSE 前,先用 `npm run first-run -- --base-url <url> --json` 或 `npm run agent:doctor -- --base-url <url>` 检查 `page_sse_auth_available_to_process` / `page_sse_auth_ready`。
204
 
205
  1. 只读检查当前服务能力,不触发计费:
206
 
@@ -211,7 +197,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
211
  "capability check"
212
  ```
213
 
214
- 2. dry-run 单张文生图,确认请求字段和路由:
215
 
216
  ```bash
217
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
@@ -223,7 +209,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
223
  "a clean product photo of a ceramic mug"
224
  ```
225
 
226
- 3. 用户明确允许后,再执行真实计费请求:
227
 
228
  ```bash
229
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
@@ -237,7 +223,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
237
  "a clean product photo of a ceramic mug"
238
  ```
239
 
240
- 4. 图生图默认 WebP 输出,走页面 SSE 路径
241
 
242
  ```bash
243
  node skills/gpt-image-playground-agent/scripts/edit-image.mjs \
@@ -266,17 +252,6 @@ node skills/gpt-image-playground-agent/scripts/batch-images.mjs \
266
  --ordered-prefix product-set
267
  ```
268
 
269
- 真实批量执行时再添加 `--allow-billable`。多张真实任务优先使用 `batch-images.mjs`、`--manifest`、`--resume` 和 `--dimension-check`;不要手动并行启动多个单张脚本,否则会绕过续跑记录、容量反馈和尺寸门禁。需要并发时添加 `--concurrency N`,并确认 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 或 `channelQueue.capacityPerCredential` 允许;建议并发为 `1` 时保持串行。
270
-
271
- 页面 SSE 返回 503 或断流时,先用诊断脚本读取结构化摘要,再用新的幂等键���式选择备用路径。`edit-image.mjs --agent --stream-mode non_stream --streaming-strategy off` 只适合作为对照诊断;Agent edit 输出格式和尺寸可能与页面 SSE 不完全一致,尺寸敏感任务必须重新校验或用 `--dimension-check`。
272
-
273
- 排查环境配置时不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。Codex 会话日志会持久保存命令输出;优先运行 `npm run env:summary`,或在命令中先把 `API_KEY`、`TOKEN`、`PASSWORD`、`SECRET` 值替换为 `<redacted>`。
274
-
275
- ```bash
276
- npm run env:summary
277
- npm run env:summary -- --file .env.local --container gpt-image-playground-customer
278
- ```
279
-
280
  6. 失败或结果需要追踪时,用诊断脚本读摘要:
281
 
282
  ```bash
@@ -285,7 +260,7 @@ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
285
  --idempotency-key agent-demo-generate-001
286
  ```
287
 
288
- 页面 SSE 请求通常用同一个业务 key 作为 `clientRequestId`,也可以这样查
289
 
290
  ```bash
291
  node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
@@ -293,61 +268,20 @@ node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
293
  --client-request-id agent-demo-edit-001
294
  ```
295
 
296
- 远程 Space、云服务或内网服务必须显式固定目标地址,避免误查本机默认服务
297
-
298
- ```bash
299
- node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
300
- --base-url https://your-space.hf.space \
301
- --idempotency-key agent-demo-generate-001
302
- ```
303
-
304
- 首次配置和诊断输出字段速查:
305
-
306
- | 字段 | 出现位置 | 判断口径 |
307
- | --- | --- | --- |
308
- | `service_base_url` / `verification_scope.service_base_url` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 当前脚本准备访问的 Playground 服务地址。 |
309
- | `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` 表示默认本地探测。 |
310
- | `interactive_confirmation_required` / `verification_scope.interactive_confirmation_required` | `first-run`、`agent:doctor`、诊断脚本为顶层;skill 脚本 dry-run 在 `verification_scope` 下 | 交互式任务中为 `true` 时,应先向用户确认是否使用该地址再发起真实请求。 |
311
- | `agent_auth_process.has_token` | `first-run --json` | 当前进程是否已经拿到 `GPT_IMAGE_AGENT_TOKEN`。 |
312
- | `page_sse_auth_available_to_process` | `first-run --json` | 目标服务要求页面 SSE `passwordHash` 时,当前进程是否已加载 `GPT_IMAGE_APP_PASSWORD_HASH`。 |
313
- | `summary.page_sse_auth_ready` | `agent:doctor` | 页面 SSE 鉴权是否已满足;为 `false` 时不要运行 `--page-sse` 真实计费请求。 |
314
- | `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
315
- | `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
316
- | `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
317
-
318
- 常用环境变量:
319
-
320
- | 变量 | 用途 |
321
- | --- | --- |
322
- | `GPT_IMAGE_PLAYGROUND_URL` | 指向本机、内网或公网部署地址;未设置时脚本默认尝试 `http://localhost:4783`。交互式任务中,自动发现到本地服务后应先向用户确认。 |
323
- | `GPT_IMAGE_AGENT_TOKEN` | Agent Bearer token,对应服务端 `AGENT_API_TOKEN`。 |
324
- | `GPT_IMAGE_APP_PASSWORD_HASH` | 使用页面访问码部署时的访问码哈希;页面 SSE 会作为 `passwordHash` 表单字段发送。 |
325
- | `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY` | 跨脚本进程复用同一业务操作的幂等键。 |
326
-
327
- Hugging Face Space Secrets 只能写入和列出名称,不能从 CLI 读回 secret 值。远端 Space 配置了 `AGENT_API_TOKEN` 后,本机 Agent 仍需要通过不入库的 shell 环境、keychain 或本地私有 env 文件注入 `GPT_IMAGE_AGENT_TOKEN`;如果 Space 同时配置了 `APP_PASSWORD`,页面 SSE 还需要 `GPT_IMAGE_APP_PASSWORD_HASH`。Agent CLI 默认读取当前仓库根目录的 `.env.agent.local`,shell 环境变量优先。不要把 token、访问码或哈希写进 README、任务 JSONL、manifest 或命令日志。仓库提供 `.env.agent.local.example` 作为私有本机配置模板。
328
-
329
- 接口边界:
330
 
331
- - `/api/agent/*` 是自动化机器契约返回最终 JSON,不端返回 SSE
332
- - `POST /api/images` 是 WebUI form-data 路径支持页面 SSE、页面访问码表单鉴权和高级图片字段
333
- - `GET /api/runtime-capabilities` 是页面运行态能力 API,进入 Agent OpenAPI
334
- - 页面反馈、分享、��志和文件删除 API 使用页面鉴权或页面文件名契约,不复用 Agent Bearer token
 
 
335
  - 选择 `responses-image-generation` 或兼容别名 `responses` 时,`partial_images` 必须优先按 `partial_images_by_backend["responses-image-generation"]` 校验,不能套用 Matsca Images API 的范围。
336
- - 灵感相册和历史复用是浏览器工作台体验,不作为机器 API 契约承诺
337
-
338
- 边界矩阵:
339
-
340
- | 能力或端点 | 归属契约 | 进入 Agent OpenAPI | 自动化 |
341
- | --- | --- | --- | --- |
342
- | `POST /api/agent/images/generate`、`POST /api/agent/images/edit`、Agent jobs、Agent artifacts | Agent API | 是 | 通过 skill 脚本和 Agent 鉴权调用。 |
343
- | `POST /api/images` | 页面 form-data SSE API | 否 | 默认 WebP edit、页面高级字段、大图或复杂批量需要时由 skill 显式选择。 |
344
- | `GET /api/runtime-capabilities` | 页面运行态能力 API | 否 | 只读查看流式默认值、图片上游传输、渠道健康和队列状态。 |
345
- | `PUT/DELETE /api/feedback` | 页面结果反馈 API | 否 | 页面写入和清理反馈;Agent 只读查询用 `/api/agent/page-requests/feedback` 或 `/api/agent/page-requests/{id}/feedback`。 |
346
- | `POST /api/agent/page-requests/feedback`、`GET /api/agent/page-requests/{id}/feedback` | Agent 结果反馈只读 API | 是 | Agent 按页面 `clientRequestId` 查询反馈。 |
347
- | `POST /api/agent/diagnostics/page-requests`、`GET /api/agent/diagnostics/page-requests/{id}` | Agent 页面请求诊断 API | 是 | Agent 按页面 `clientRequestId` 查询脱敏日志摘要,不直接读 `/api/logs`。 |
348
- | `GET /api/logs` | 页面日志 SSE API | 否 | 页面使用访问码哈希读取;不接受 Agent token。 |
349
- | `POST /api/shares`、`GET /api/shares/{token}`、`POST /api/shares/{token}/content` | 页面分享 API | 否 | 使用页面 cookie、访问码和分享 token。 |
350
- | `POST /api/image-delete` | 页面图片文件删除 API | 否 | 按页面文件名删除 `generated-images/` 文件,不删除 Agent artifact 状态。 |
351
 
352
  ## Docker 与部署
353
 
 
17
 
18
  ## 快速开始
19
 
20
+ 第一次配置或换机器后,先跑只读就绪检查。它不会写配置、输出密钥触发真实生图:
21
 
22
  ```bash
23
  npm run first-run
24
  ```
25
 
26
+ 检查公网或 Space 服务时显式传地址;给脚本消费时加 `--json`
27
 
28
  ```bash
29
  npm run first-run -- --base-url https://your-space.hf.space
30
  npm run first-run -- --json --base-url https://your-space.hf.space
31
  ```
32
 
33
+ 本地服务推荐用 Docker
 
 
 
 
 
 
 
34
 
35
  ```bash
36
  docker compose up -d --build --remove-orphans
 
49
  OPENAI_API_BASE_URL=https://api.openai.com/v1
50
  ```
51
 
52
+ 开发模式
53
 
54
  ```bash
55
  npm install
 
99
  | 图片存储 | `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | `fs` 或 `indexeddb`。Docker 默认使用 `fs`。 |
100
  | Agent 状态 | `AGENT_STATE_BACKEND` | `memory`、`sqlite` 或 `postgres`。Docker 默认使用 `sqlite`。 |
101
  | 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
102
+ | Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
103
  | 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
104
  | 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
105
  | 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
 
136
 
137
  - 自定义 API URL 必须同时填写自定义 API Key,避免服务端密钥被发送到未知地址。
138
  - Docker compose 本身不把默认图片后端改成 Responses;未在 `.env.local` 显式配置时仍是 `images-api` 和 `auto`。
139
+ - Responses image backend 需要 `ENABLE_RESPONSES_IMAGE_BACKEND=true` 和 `OPENAI_RESPONSES_API_MODEL`。页面请求也可以用 `responsesModel`、`responses_model`、`gptModel` 或 `gpt_model` 覆盖单次 `/responses` 顶层模型;这些字段只影响本项目的 `responses-image-generation` 路径,不会改变兼容上游自身 `/v1/images/generations` 桥接层内部选择的模型。
140
  - Matsca、extra headers、provider manifest、真实上游 smoke 等高级配置以 [.env.example](./.env.example) 为准。
141
 
142
  ## Agent API
143
 
144
+ Agent API 是机器接口,不是自治 Agent 平台。自动化客户端应先读取 capabilities,再按返回的认证、路由和端点能力选择路径。
145
+
146
+ 新增 probe、diagnostics 或路由可观测能力时,先落 API / capabilities / OpenAPI 契约,再让 Skill 脚本做薄封装;不要让脚本自己复制页面与 Agent 的边界判断。
147
 
148
  常用入口:
149
 
150
  | 接口 | 用途 |
151
  | --- | --- |
152
+ | `GET /api/agent/capabilities` | 查询模型、限制、认证方式和路由规则。 |
153
  | `GET /api/agent/openapi.json` | 获取 OpenAPI 描述。 |
154
  | `POST /api/agent/images/generate` | JSON 文生图。 |
155
  | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 mask。 |
 
158
  | `GET /api/agent/jobs/{id}/result` | 读取成功 job 的标准图片响应。 |
159
  | `GET /api/agent/artifacts/{id}/content` | 下载产物图片。 |
160
  | `POST /api/agent/diagnostics/page-requests` | 批量读取页面请求的脱敏日志诊断摘要。 |
161
+ | `GET /api/agent/diagnostics/requests` | 按 request id 或幂等键查询诊断。 |
162
 
163
  生成示例:
164
 
 
175
  - [skills/gpt-image-playground-agent/SKILL.md](./skills/gpt-image-playground-agent/SKILL.md)
176
  - [Agent API 参考](./skills/gpt-image-playground-agent/references/api.md)
177
 
178
+ 脚本默认 dry-run,不触发真实计费请求;真实生成必须显式添加 `--allow-billable`
179
 
180
  ```bash
181
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
 
186
 
187
  ### AI Agent 典型使用方式
188
 
189
+ AI Agent 集成时优先调用 skill 脚本,不临时手写 fetch、curl 或表单提交逻辑。脚本会处理 capabilities、鉴权、幂等键、路由、超时、产物 URL 和结构化失败摘要。
 
 
 
 
 
 
 
 
 
 
190
 
191
  1. 只读检查当前服务能力,不触发计费:
192
 
 
197
  "capability check"
198
  ```
199
 
200
+ 2. dry-run 单张文生图,确认请求字段和路由:
201
 
202
  ```bash
203
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
 
209
  "a clean product photo of a ceramic mug"
210
  ```
211
 
212
+ 3. 用户明确允许后执行真实请求:
213
 
214
  ```bash
215
  node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
 
223
  "a clean product photo of a ceramic mug"
224
  ```
225
 
226
+ 4. 图生图:
227
 
228
  ```bash
229
  node skills/gpt-image-playground-agent/scripts/edit-image.mjs \
 
252
  --ordered-prefix product-set
253
  ```
254
 
 
 
 
 
 
 
 
 
 
 
 
255
  6. 失败或结果需要追踪时,用诊断脚本读摘要:
256
 
257
  ```bash
 
260
  --idempotency-key agent-demo-generate-001
261
  ```
262
 
263
+ 页面 SSE 请求通常用同一个业务 key 作为 `clientRequestId`:
264
 
265
  ```bash
266
  node skills/gpt-image-playground-agent/scripts/diagnose-request.mjs \
 
268
  --client-request-id agent-demo-edit-001
269
  ```
270
 
271
+ 关键规则
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
 
273
+ - 交互式任务中,如果只发现本地服务或 `GPT_IMAGE_PLAYGROUND_URL`,确认;用户给了 URL 时以用户 URL 为准
274
+ - 远程 Space、云服务或内网服务必须显式传 `--base-url`避免误用本机默认服务
275
+ - Agent CLI 默认读取当前仓库根目录的 `.env.agent.local`;shell 环境变量优先。首次配置可复制 `.env.agent.local.example`。要提交 token、访问码或哈希
276
+ - `GPT_IMAGE_AGENT_TOKEN` 于 `/api/agent/*`;页面 SSE `/api/images` 可能还需要 `GPT_IMAGE_APP_PASSWORD_HASH`
277
+ - dry-run 只验证本地请求构造;`verification_scope.mode=local_planning_only` 不是远端已可执行。远端合同检查用 `--contract-check`,真实执行必须加 `--allow-billable`。
278
+ - 多张真实任务优先用 `batch-images.mjs`、`--manifest`、`--resume` 和 `--dimension-check`;不要手动并行启动多个单张脚本。需要并发时先看 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`。
279
  - 选择 `responses-image-generation` 或兼容别名 `responses` 时,`partial_images` 必须优先按 `partial_images_by_backend["responses-image-generation"]` 校验,不能套用 Matsca Images API 的范围。
280
+ - 页面 SSE 返回 503 或断流时,先诊断脚本读取结构化摘要,再用新的幂等键显式选择备用路径。`page_sse_supported=true` 只声明支持,不代表实测一定成功。Agent edit 输出格式和尺寸可能与页面 SSE 不完全一致,尺寸敏感任务必须重新校验或用 `--dimension-check`
281
+ - 排查环境配置时优先运行 `npm run env:summary`,不要直接输出 `.env.local`、`.env*.local`、secret 文件或原始 `docker inspect .Config.Env`。
282
+ - Hugging Face Space Secrets 只能写入和列出名称,不能从 CLI 读回 secret 值。
283
+ - 边界矩阵精简版:
284
+ - `/api/agent/*` 返回最终 JSON;`POST /api/images`、`GET /api/runtime-capabilities`、`/api/feedback`、`/api/shares`、`/api/logs` 和 `POST /api/image-delete` 属于页面或运行态 API,不进入 Agent OpenAPI。Agent 只读反馈和诊断入 `/api/agent/page-requests/feedback`、`/api/agent/page-requests/{id}/feedback`、`/api/agent/diagnostics/page-requests` 和 `/api/agent/diagnostics/page-requests/{id}`。灵感相册和历史复用是浏览器工作台体验,不作为机器 API 契约承诺。
 
 
 
 
 
 
 
 
 
 
285
 
286
  ## Docker 与部署
287
 
scripts/agent-doctor.mjs CHANGED
@@ -11,6 +11,7 @@ import {
11
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
12
  const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
13
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
 
14
 
15
  export function buildAgentDoctorArgs() {
16
  return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
@@ -129,6 +130,7 @@ function buildSkippedSmoke(options) {
129
  reason: 'requires --allow-billable',
130
  checks: [
131
  { name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' },
 
132
  {
133
  name: 'edit_1k',
134
  skipped: true,
@@ -160,6 +162,28 @@ function runBillableSmoke(options, baseUrl) {
160
  '--idempotency-key',
161
  `agent-doctor-generate-${Date.now()}`,
162
  'agent doctor 1k generate smoke'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  ])
164
  ];
165
  if (options.editImage) {
@@ -266,6 +290,7 @@ function buildLayers({ capabilities, runtime, contract, smoke }) {
266
  function summarizeCapabilities(body) {
267
  return {
268
  page_sse: body?.agent_streaming?.page_sse?.supported === true,
 
269
  page_sse_auth_required: body?.agent_streaming?.page_sse?.auth?.required === true,
270
  page_sse_auth_ready:
271
  body?.agent_streaming?.page_sse?.auth?.required === true
@@ -301,6 +326,7 @@ function summarizeStateBackend(body) {
301
  function summarizeResponsesReadiness(capabilities, runtime) {
302
  const requirements = capabilities?.supported?.image_backend_requirements?.['responses-image-generation'];
303
  return {
 
304
  backend_supported: requirements?.supported === true,
305
  backend_enabled: requirements?.enabled === true,
306
  runtime_enabled: runtime?.responsesImageBackend?.enabled === true,
@@ -317,19 +343,32 @@ function buildSummary({ capabilities, runtime, contract, smoke }) {
317
  contract_check: contract.ok ? 'ok' : 'failed',
318
  runtime: runtime.ok ? 'ok' : 'failed',
319
  state_backend: capabilities.ok ? capabilities.body?.defaults?.state_backend : 'unknown',
 
 
 
320
  page_sse_auth_ready:
321
  capabilities.ok && capabilities.body?.agent_streaming?.page_sse?.auth?.required === true
322
  ? Boolean(process.env.GPT_IMAGE_APP_PASSWORD_HASH)
323
  : capabilities.ok,
 
324
  responses_gpt2image_ready:
325
  capabilities.ok && runtime.ok
326
  ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true &&
327
  runtime.body?.responsesImageBackend?.enabled === true
328
  : false,
 
 
 
329
  billable_smoke: smoke.skipped ? 'skipped' : smoke.ok ? 'ok' : 'failed'
330
  };
331
  }
332
 
 
 
 
 
 
 
333
  function authHeaders() {
334
  if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
335
  if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
 
11
  const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
12
  const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
13
  const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
14
+ const PAGE_SSE_GENERATE_SMOKE_NAME = 'responses_page_sse_generate_1k';
15
 
16
  export function buildAgentDoctorArgs() {
17
  return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
 
130
  reason: 'requires --allow-billable',
131
  checks: [
132
  { name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' },
133
+ { name: PAGE_SSE_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
134
  {
135
  name: 'edit_1k',
136
  skipped: true,
 
162
  '--idempotency-key',
163
  `agent-doctor-generate-${Date.now()}`,
164
  'agent doctor 1k generate smoke'
165
+ ]),
166
+ runSmokeCommand(PAGE_SSE_GENERATE_SMOKE_NAME, [
167
+ GENERATE_SCRIPT,
168
+ '--base-url',
169
+ baseUrl,
170
+ '--allow-billable',
171
+ '--page-sse',
172
+ '--timeout-ms',
173
+ String(options.timeoutMs),
174
+ '--size',
175
+ '1024x1024',
176
+ '--quality',
177
+ 'low',
178
+ '--image-backend',
179
+ 'responses-image-generation',
180
+ '--stream-mode',
181
+ 'stream',
182
+ '--streaming-strategy',
183
+ 'responses-sse',
184
+ '--idempotency-key',
185
+ `agent-doctor-responses-page-sse-generate-${Date.now()}`,
186
+ 'agent doctor responses page SSE generate smoke'
187
  ])
188
  ];
189
  if (options.editImage) {
 
290
  function summarizeCapabilities(body) {
291
  return {
292
  page_sse: body?.agent_streaming?.page_sse?.supported === true,
293
+ page_sse_declared_supported: body?.agent_streaming?.page_sse?.supported === true,
294
  page_sse_auth_required: body?.agent_streaming?.page_sse?.auth?.required === true,
295
  page_sse_auth_ready:
296
  body?.agent_streaming?.page_sse?.auth?.required === true
 
326
  function summarizeResponsesReadiness(capabilities, runtime) {
327
  const requirements = capabilities?.supported?.image_backend_requirements?.['responses-image-generation'];
328
  return {
329
+ declared_supported: requirements?.supported === true,
330
  backend_supported: requirements?.supported === true,
331
  backend_enabled: requirements?.enabled === true,
332
  runtime_enabled: runtime?.responsesImageBackend?.enabled === true,
 
343
  contract_check: contract.ok ? 'ok' : 'failed',
344
  runtime: runtime.ok ? 'ok' : 'failed',
345
  state_backend: capabilities.ok ? capabilities.body?.defaults?.state_backend : 'unknown',
346
+ page_sse_declared_supported: capabilities.ok
347
+ ? capabilities.body?.agent_streaming?.page_sse?.supported === true
348
+ : false,
349
  page_sse_auth_ready:
350
  capabilities.ok && capabilities.body?.agent_streaming?.page_sse?.auth?.required === true
351
  ? Boolean(process.env.GPT_IMAGE_APP_PASSWORD_HASH)
352
  : capabilities.ok,
353
+ page_sse_real_smoke: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
354
  responses_gpt2image_ready:
355
  capabilities.ok && runtime.ok
356
  ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true &&
357
  runtime.body?.responsesImageBackend?.enabled === true
358
  : false,
359
+ responses_image_backend_declared_supported: capabilities.ok
360
+ ? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.supported === true
361
+ : false,
362
  billable_smoke: smoke.skipped ? 'skipped' : smoke.ok ? 'ok' : 'failed'
363
  };
364
  }
365
 
366
+ function summarizeSmokeCheck(smoke, name) {
367
+ const check = smoke.checks?.find((item) => item.name === name);
368
+ if (!check || check.skipped) return 'skipped';
369
+ return check.ok ? 'passed' : 'failed';
370
+ }
371
+
372
  function authHeaders() {
373
  if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
374
  if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
scripts/agent-skill-scripts.test.mjs CHANGED
@@ -3,7 +3,8 @@ import {
3
  readCapabilitiesImageTransportTimeoutMs,
4
  resolveSameOriginUrl
5
  } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
6
- import { AGENT_ENDPOINTS } from '../src/lib/agent-api-paths.mjs';
 
7
  import assert from 'node:assert/strict';
8
  import { spawn, spawnSync } from 'node:child_process';
9
  import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
@@ -85,6 +86,89 @@ describe('Agent skill script argument validation', () => {
85
  assert.equal(probeResult.stdout.trim(), '');
86
  });
87
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  it('converts local images to webp by default', () => {
89
  const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-convert-'));
90
  try {
@@ -1138,6 +1222,243 @@ describe('Agent skill script argument validation', () => {
1138
  );
1139
  });
1140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1141
  it('uses page SSE client request id max length declared by capabilities', async () => {
1142
  const requests = [];
1143
  await withServer(
@@ -1615,7 +1936,9 @@ describe('Agent skill script argument validation', () => {
1615
  {
1616
  filename: 'agent-off.png',
1617
  content_url: '/api/agent/artifacts/artifact-off/content',
1618
- metadata_url: '/api/agent/artifacts/artifact-off'
 
 
1619
  }
1620
  ],
1621
  timing: { server_elapsed_ms: 4321 }
@@ -1660,6 +1983,8 @@ describe('Agent skill script argument validation', () => {
1660
  assert.equal(body.summary.route_mode, 'agent');
1661
  assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-off/content']);
1662
  assert.deepEqual(body.summary.absolute_content_urls, [`${baseUrl}/api/agent/artifacts/artifact-off/content`]);
 
 
1663
  assert.equal(typeof body.summary.elapsed_ms, 'number');
1664
  assert.equal(body.summary.elapsed_source, 'client_script');
1665
  assert.equal(body.summary.server_elapsed_ms, 4321);
@@ -2352,6 +2677,102 @@ describe('Agent skill script argument validation', () => {
2352
  }
2353
  });
2354
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2355
  it('rejects default WebP edit dry-runs when streaming is explicitly disabled', () => {
2356
  const result = runSkillScript('edit-image.mjs', [
2357
  '--size',
@@ -2778,10 +3199,14 @@ describe('Agent skill script argument validation', () => {
2778
  assert.match(skillText, /脚本参数仍写作 `--streaming-strategy responses-sse`/);
2779
  assert.match(skillText, /batch JSONL 字段是 `streaming_strategy`/);
2780
  assert.match(skillText, /`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段/);
 
 
2781
  assert.match(skillText, /支持位置参数 `<image-path> <prompt>`,也支持 `--image <path> <prompt>` 别名/);
2782
  assert.match(skillText, /`--image-backend responses-image-generation` 只用于页面 SSE edit/);
2783
  assert.match(skillText, /不要把 Matsca `limits\.partial_images=0\.\.4` 误套到 `responses-image-generation`/);
2784
  assert.match(apiReference, /limits\.partial_images_by_backend\[image_backend\]/);
 
 
2785
  assert.match(apiReference, /Agent edit 不接收 `image_backend`、`output_format` 或 `output_compression`/);
2786
  assert.match(apiReference, /强制 Agent edit 时输出格式固定为 PNG,`partial_images` 按默认 Images API\/profile 范围校验/);
2787
  assert.match(apiReference, /图片路径可以用位置参数 `<image-path> <prompt>`,也可以用 `--image <path> <prompt>`/);
@@ -2819,6 +3244,7 @@ describe('Agent skill script argument validation', () => {
2819
  assert.match(readmeText, /npm run env:summary/);
2820
  assert.match(readmeText, /verification_scope\.mode=local_planning_only/);
2821
  assert.match(readmeText, /Hugging Face Space Secrets 只能写入和列出名称/);
 
2822
 
2823
  assert.match(skillText, /不要手动并行启动多个单张脚本/);
2824
  assert.match(skillText, /capacity_feedback/);
@@ -2840,6 +3266,8 @@ describe('Agent skill script argument validation', () => {
2840
  assert.match(apiReference, /verification_scope\.mode=local_planning_only/);
2841
  assert.match(apiReference, /manifest_written=false/);
2842
  assert.match(apiReference, /Hugging Face Space Secrets 只能写入和列出名称/);
 
 
2843
  });
2844
 
2845
  it('keeps WebUI page APIs out of the Agent OpenAPI contract', () => {
@@ -2894,6 +3322,24 @@ describe('Agent skill script argument validation', () => {
2894
  assert.match(openApiSource, /高分辨率 edit 默认优先使用页面端 \/api\/images form-data SSE/);
2895
  });
2896
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2897
  it('runs from a copied standalone skill directory outside the repository', () => {
2898
  const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-playground-agent-'));
2899
  const copiedSkillRoot = join(tempRoot, 'gpt-image-playground-agent');
@@ -2997,6 +3443,10 @@ describe('Agent skill script argument validation', () => {
2997
  assert.equal(body.manifest_write_reason, 'dry_run');
2998
  assert.equal(body.total, 2);
2999
  assert.equal(body.concurrency, 1);
 
 
 
 
3000
  assert.equal(body.tasks[0].endpoint, '/api/agent/images/generate');
3001
  assert.equal(body.tasks[0].idempotency_key, 'demo-0001-first-item');
3002
  assert.equal(body.tasks[0].request.model, 'gpt-image-2');
@@ -3017,6 +3467,77 @@ describe('Agent skill script argument validation', () => {
3017
  }
3018
  });
3019
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3020
  it('rejects invalid batch concurrency before dry-run output', () => {
3021
  const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrency-'));
3022
  try {
@@ -4473,6 +4994,8 @@ describe('Agent skill script argument validation', () => {
4473
  assert.equal(result.stderr.trim(), '');
4474
  const body = JSON.parse(result.stdout);
4475
  assert.equal(body.results[0].status, 'succeeded');
 
 
4476
  }
4477
  );
4478
  } finally {
@@ -4912,7 +5435,14 @@ describe('Agent skill script argument validation', () => {
4912
  }
4913
  if (request.url === '/api/agent/images/generate') {
4914
  response.writeHead(200, { 'content-type': 'application/json' });
4915
- response.end(JSON.stringify({ images: [{ id: 'dim-image', filename: 'dim.png', b64_json: fakePngBase64(512, 512) }] }));
 
 
 
 
 
 
 
4916
  return;
4917
  }
4918
  response.writeHead(404, { 'content-type': 'application/json' });
@@ -4929,7 +5459,29 @@ describe('Agent skill script argument validation', () => {
4929
  assert.equal(result.stderr.trim(), '');
4930
  const body = JSON.parse(result.stdout);
4931
  assert.equal(body.ok, false);
4932
- assert.match(body.results[0].error, /尺寸校验失败/);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4933
  }
4934
  );
4935
  } finally {
@@ -5685,9 +6237,10 @@ function listTextFiles(root) {
5685
 
5686
  function runSkillScriptAsync(filename, args, env = {}, options = {}) {
5687
  return new Promise((resolve) => {
 
5688
  const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], {
5689
  cwd: repoRoot,
5690
- env: { ...process.env, ...env },
5691
  stdio: ['ignore', 'pipe', 'pipe']
5692
  });
5693
  let stdout = '';
 
3
  readCapabilitiesImageTransportTimeoutMs,
4
  resolveSameOriginUrl
5
  } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
6
+ import { enrichFailureWithAgentDiagnostics } from '../skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs';
7
+ import { AGENT_ENDPOINTS } from '../skills/gpt-image-playground-agent/scripts/lib/agent-api-paths.mjs';
8
  import assert from 'node:assert/strict';
9
  import { spawn, spawnSync } from 'node:child_process';
10
  import { cpSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
 
86
  assert.equal(probeResult.stdout.trim(), '');
87
  });
88
 
89
+ it('caps Agent diagnostics enrichment timeout independently of image request timeouts', async () => {
90
+ const startedAt = Date.now();
91
+ const result = await enrichFailureWithAgentDiagnostics({
92
+ baseUrl: 'http://example.test/playground',
93
+ authHeaders: () => ({}),
94
+ idempotencyKey: 'slow-diagnostics-key',
95
+ failureOutput: { ok: false, error: { code: 'network_error' } },
96
+ summary: {
97
+ ok: false,
98
+ retryable: true,
99
+ next_action: 'retry_after_wait'
100
+ },
101
+ timeoutMs: 420000,
102
+ diagnosticsTimeoutMs: 25,
103
+ fetchFn: (_url, init) =>
104
+ new Promise((_resolve, reject) => {
105
+ init.signal.addEventListener('abort', () => {
106
+ const error = new Error('aborted');
107
+ error.name = 'AbortError';
108
+ reject(error);
109
+ });
110
+ })
111
+ });
112
+
113
+ assert.equal(result.summary.agent_diagnostics_checked, true);
114
+ assert.equal(result.summary.agent_diagnostics_found, false);
115
+ assert.equal(result.summary.agent_diagnostics_unavailable_reason, 'diagnostics_timeout');
116
+ assert.equal(result.failureOutput.agent_failure_diagnostics.unavailable_reason, 'diagnostics_timeout');
117
+ assert.equal(result.summary.retryable, true);
118
+ assert.equal(result.summary.next_action, 'retry_after_wait');
119
+ assert.ok(Date.now() - startedAt < 1000);
120
+ });
121
+
122
+ it('reports non-json Agent diagnostics responses distinctly', async () => {
123
+ const result = await enrichFailureWithAgentDiagnostics({
124
+ baseUrl: 'http://example.test/playground',
125
+ authHeaders: () => ({}),
126
+ idempotencyKey: 'html-diagnostics-key',
127
+ failureOutput: { ok: false, error: { code: 'network_error' } },
128
+ summary: { ok: false },
129
+ timeoutMs: 420000,
130
+ fetchFn: async () => ({
131
+ ok: false,
132
+ status: 502,
133
+ headers: new Headers({ 'content-type': 'text/html' }),
134
+ text: async () => '<html>bad gateway</html>'
135
+ })
136
+ });
137
+
138
+ assert.equal(result.summary.agent_diagnostics_checked, true);
139
+ assert.equal(result.summary.agent_diagnostics_found, false);
140
+ assert.equal(result.summary.agent_diagnostics_unavailable_reason, 'non_json_response');
141
+ assert.equal(result.summary.agent_diagnostics_http_status, 502);
142
+ assert.equal(result.failureOutput.agent_failure_diagnostics.unavailable_reason, 'non_json_response');
143
+ assert.equal(result.failureOutput.agent_failure_diagnostics.http_status, 502);
144
+ });
145
+
146
+ it('treats found Agent diagnostics without payload as invalid', async () => {
147
+ const result = await enrichFailureWithAgentDiagnostics({
148
+ baseUrl: 'http://example.test/playground',
149
+ authHeaders: () => ({}),
150
+ idempotencyKey: 'missing-diagnostics-key',
151
+ failureOutput: { ok: false, error: { code: 'network_error' } },
152
+ summary: { ok: false },
153
+ timeoutMs: 420000,
154
+ fetchFn: async () => ({
155
+ ok: true,
156
+ status: 200,
157
+ headers: new Headers({ 'content-type': 'application/json' }),
158
+ text: async () => JSON.stringify({ found: true })
159
+ })
160
+ });
161
+
162
+ assert.equal(result.summary.agent_diagnostics_checked, true);
163
+ assert.equal(result.summary.agent_diagnostics_found, false);
164
+ assert.equal(result.summary.agent_diagnostics_unavailable_reason, 'invalid_response');
165
+ assert.equal(result.summary.agent_diagnostics_http_status, 200);
166
+ assert.equal(result.failureOutput.agent_failure_diagnostics.found, false);
167
+ assert.equal(result.failureOutput.agent_failure_diagnostics.unavailable_reason, 'invalid_response');
168
+ assert.equal(result.failureOutput.agent_failure_diagnostics.http_status, 200);
169
+ assert.equal(result.failureOutput.agent_failure_diagnostics.request_id, undefined);
170
+ });
171
+
172
  it('converts local images to webp by default', () => {
173
  const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-convert-'));
174
  try {
 
1222
  );
1223
  });
1224
 
1225
+ it('enriches failed Agent generate summaries with Agent state diagnostics', async () => {
1226
+ const requests = [];
1227
+ await withServer(
1228
+ (request, response) => {
1229
+ requests.push({ method: request.method, url: request.url });
1230
+ if (request.url === '/api/agent/capabilities') {
1231
+ response.writeHead(200, { 'content-type': 'application/json' });
1232
+ response.end(JSON.stringify({ ok: true }));
1233
+ return;
1234
+ }
1235
+ if (request.url === '/api/agent/images/generate') {
1236
+ response.writeHead(500, { 'content-type': 'application/json' });
1237
+ response.end(JSON.stringify({ error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } }));
1238
+ return;
1239
+ }
1240
+ if (request.url === '/api/agent/diagnostics/requests?idempotency_key=diag-generate-key') {
1241
+ response.writeHead(200, { 'content-type': 'application/json' });
1242
+ response.end(
1243
+ JSON.stringify({
1244
+ found: true,
1245
+ diagnostics: {
1246
+ request: {
1247
+ request_id: 'req_generate_diag',
1248
+ idempotency_key: 'diag-generate-key',
1249
+ status: 'failed'
1250
+ },
1251
+ error: {
1252
+ code: 'unexpected_error',
1253
+ retryable: false,
1254
+ diagnostics: {
1255
+ selected_channel_id: 'channel-a',
1256
+ upstream_host: 'upstream.example.test',
1257
+ transport_error_kind: 'aborted'
1258
+ }
1259
+ }
1260
+ }
1261
+ })
1262
+ );
1263
+ return;
1264
+ }
1265
+ response.writeHead(404, { 'content-type': 'application/json' });
1266
+ response.end(JSON.stringify({ error: 'missing' }));
1267
+ },
1268
+ async (baseUrl) => {
1269
+ const result = await runSkillScriptAsync(
1270
+ 'generate-image.mjs',
1271
+ ['--allow-billable', '--idempotency-key', 'diag-generate-key', '--size', '1024x1024', 'prompt'],
1272
+ { GPT_IMAGE_PLAYGROUND_URL: baseUrl }
1273
+ );
1274
+
1275
+ assert.equal(result.status, 1);
1276
+ assert.equal(result.stdout.trim(), '');
1277
+ const body = JSON.parse(result.stderr);
1278
+ assert.equal(body.summary.request_id, 'req_generate_diag');
1279
+ assert.equal(body.summary.selected_channel_id, 'channel-a');
1280
+ assert.equal(body.summary.upstream_host, 'upstream.example.test');
1281
+ assert.equal(body.summary.transport_error_kind, 'aborted');
1282
+ assert.equal(body.summary.agent_diagnostics_checked, true);
1283
+ assert.equal(body.summary.agent_diagnostics_found, true);
1284
+ assert.equal(body.agent_failure_diagnostics.request_id, 'req_generate_diag');
1285
+ assert.equal(body.agent_failure_diagnostics.status, 'failed');
1286
+ assert.deepEqual(
1287
+ requests.map((item) => `${item.method} ${item.url}`),
1288
+ [
1289
+ 'GET /api/agent/capabilities',
1290
+ 'POST /api/agent/images/generate',
1291
+ 'GET /api/agent/diagnostics/requests?idempotency_key=diag-generate-key'
1292
+ ]
1293
+ );
1294
+ }
1295
+ );
1296
+ });
1297
+
1298
+ it('preserves path prefixes when fetching Agent state diagnostics', async () => {
1299
+ const requests = [];
1300
+ await withServer(
1301
+ (request, response) => {
1302
+ requests.push({ method: request.method, url: request.url });
1303
+ if (request.url === '/playground/api/agent/capabilities') {
1304
+ response.writeHead(200, { 'content-type': 'application/json' });
1305
+ response.end(JSON.stringify({ ok: true }));
1306
+ return;
1307
+ }
1308
+ if (request.url === '/playground/api/agent/images/generate') {
1309
+ response.writeHead(500, { 'content-type': 'application/json' });
1310
+ response.end(
1311
+ JSON.stringify({
1312
+ error: { code: 'unexpected_error', message: 'fetch failed', retryable: false }
1313
+ })
1314
+ );
1315
+ return;
1316
+ }
1317
+ if (request.url === '/playground/api/agent/diagnostics/requests?idempotency_key=path-diag-key') {
1318
+ response.writeHead(200, { 'content-type': 'application/json' });
1319
+ response.end(
1320
+ JSON.stringify({
1321
+ found: true,
1322
+ diagnostics: {
1323
+ request: {
1324
+ request_id: 'req_path_diag',
1325
+ idempotency_key: 'path-diag-key',
1326
+ status: 'failed'
1327
+ }
1328
+ }
1329
+ })
1330
+ );
1331
+ return;
1332
+ }
1333
+ response.writeHead(404, { 'content-type': 'application/json' });
1334
+ response.end(JSON.stringify({ error: 'missing' }));
1335
+ },
1336
+ async (baseUrl) => {
1337
+ const result = await runSkillScriptAsync(
1338
+ 'generate-image.mjs',
1339
+ ['--allow-billable', '--idempotency-key', 'path-diag-key', '--size', '1024x1024', 'prompt'],
1340
+ { GPT_IMAGE_PLAYGROUND_URL: `${baseUrl}/playground` }
1341
+ );
1342
+
1343
+ assert.equal(result.status, 1);
1344
+ assert.equal(result.stdout.trim(), '');
1345
+ const body = JSON.parse(result.stderr);
1346
+ assert.equal(body.summary.request_id, 'req_path_diag');
1347
+ assert.equal(body.summary.agent_diagnostics_checked, true);
1348
+ assert.equal(body.summary.agent_diagnostics_found, true);
1349
+ assert.deepEqual(
1350
+ requests.map((item) => `${item.method} ${item.url}`),
1351
+ [
1352
+ 'GET /playground/api/agent/capabilities',
1353
+ 'POST /playground/api/agent/images/generate',
1354
+ 'GET /playground/api/agent/diagnostics/requests?idempotency_key=path-diag-key'
1355
+ ]
1356
+ );
1357
+ }
1358
+ );
1359
+ });
1360
+
1361
+ it('replaces stale retry guidance when Agent diagnostics report a terminal failure', async () => {
1362
+ await withServer(
1363
+ (request, response) => {
1364
+ if (request.url === '/api/agent/capabilities') {
1365
+ response.writeHead(200, { 'content-type': 'application/json' });
1366
+ response.end(JSON.stringify({ ok: true }));
1367
+ return;
1368
+ }
1369
+ if (request.url === '/api/agent/images/generate') {
1370
+ response.writeHead(503, { 'content-type': 'application/json', 'retry-after': '1' });
1371
+ response.end(
1372
+ JSON.stringify({
1373
+ error: { code: 'network_error', message: 'temporary failure', retryable: true }
1374
+ })
1375
+ );
1376
+ return;
1377
+ }
1378
+ if (request.url === '/api/agent/diagnostics/requests?idempotency_key=terminal-diag-key') {
1379
+ response.writeHead(200, { 'content-type': 'application/json' });
1380
+ response.end(
1381
+ JSON.stringify({
1382
+ found: true,
1383
+ diagnostics: {
1384
+ request: {
1385
+ request_id: 'req_terminal_diag',
1386
+ idempotency_key: 'terminal-diag-key',
1387
+ status: 'failed'
1388
+ },
1389
+ error: {
1390
+ code: 'upstream_failed',
1391
+ retryable: false
1392
+ }
1393
+ }
1394
+ })
1395
+ );
1396
+ return;
1397
+ }
1398
+ response.writeHead(404, { 'content-type': 'application/json' });
1399
+ response.end(JSON.stringify({ error: 'missing' }));
1400
+ },
1401
+ async (baseUrl) => {
1402
+ const result = await runSkillScriptAsync(
1403
+ 'generate-image.mjs',
1404
+ ['--allow-billable', '--idempotency-key', 'terminal-diag-key', '--size', '1024x1024', 'prompt'],
1405
+ {
1406
+ GPT_IMAGE_AGENT_MAX_ATTEMPTS: '1',
1407
+ GPT_IMAGE_PLAYGROUND_URL: baseUrl
1408
+ }
1409
+ );
1410
+
1411
+ assert.equal(result.status, 1);
1412
+ assert.equal(result.stdout.trim(), '');
1413
+ const body = JSON.parse(result.stderr);
1414
+ assert.equal(body.summary.request_id, 'req_terminal_diag');
1415
+ assert.equal(body.summary.retryable, false);
1416
+ assert.equal(body.summary.next_action, 'diagnose_then_new_idempotency_key');
1417
+ assert.equal(body.agent_failure_diagnostics.retryable, false);
1418
+ }
1419
+ );
1420
+ });
1421
+
1422
+ it('does not report diagnostics lookup failures as upstream transport errors', async () => {
1423
+ await withServer(
1424
+ (request, response) => {
1425
+ if (request.url === '/api/agent/capabilities') {
1426
+ response.writeHead(200, { 'content-type': 'application/json' });
1427
+ response.end(JSON.stringify({ ok: true }));
1428
+ return;
1429
+ }
1430
+ if (request.url === '/api/agent/images/generate') {
1431
+ response.writeHead(500, { 'content-type': 'application/json' });
1432
+ response.end(JSON.stringify({ error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } }));
1433
+ return;
1434
+ }
1435
+ if (request.url === '/api/agent/diagnostics/requests?idempotency_key=diag-unavailable-key') {
1436
+ response.writeHead(500, { 'content-type': 'application/json' });
1437
+ response.end(JSON.stringify({ error: 'diagnostics unavailable' }));
1438
+ return;
1439
+ }
1440
+ response.writeHead(404, { 'content-type': 'application/json' });
1441
+ response.end(JSON.stringify({ error: 'missing' }));
1442
+ },
1443
+ async (baseUrl) => {
1444
+ const result = await runSkillScriptAsync(
1445
+ 'generate-image.mjs',
1446
+ ['--allow-billable', '--idempotency-key', 'diag-unavailable-key', '--size', '1024x1024', 'prompt'],
1447
+ { GPT_IMAGE_PLAYGROUND_URL: baseUrl }
1448
+ );
1449
+
1450
+ assert.equal(result.status, 1);
1451
+ assert.equal(result.stdout.trim(), '');
1452
+ const body = JSON.parse(result.stderr);
1453
+ assert.equal(body.summary.transport_error_kind, null);
1454
+ assert.equal(body.summary.agent_diagnostics_checked, true);
1455
+ assert.equal(body.summary.agent_diagnostics_found, false);
1456
+ assert.equal(body.summary.agent_diagnostics_unavailable_reason, 'status_500');
1457
+ assert.equal(body.agent_failure_diagnostics.unavailable_reason, 'status_500');
1458
+ }
1459
+ );
1460
+ });
1461
+
1462
  it('uses page SSE client request id max length declared by capabilities', async () => {
1463
  const requests = [];
1464
  await withServer(
 
1936
  {
1937
  filename: 'agent-off.png',
1938
  content_url: '/api/agent/artifacts/artifact-off/content',
1939
+ metadata_url: '/api/agent/artifacts/artifact-off',
1940
+ width: 1254,
1941
+ height: 1254
1942
  }
1943
  ],
1944
  timing: { server_elapsed_ms: 4321 }
 
1983
  assert.equal(body.summary.route_mode, 'agent');
1984
  assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-off/content']);
1985
  assert.deepEqual(body.summary.absolute_content_urls, [`${baseUrl}/api/agent/artifacts/artifact-off/content`]);
1986
+ assert.deepEqual(body.summary.actual_dimensions, { width: 1254, height: 1254 });
1987
+ assert.deepEqual(body.summary.image_dimensions, [{ width: 1254, height: 1254 }]);
1988
  assert.equal(typeof body.summary.elapsed_ms, 'number');
1989
  assert.equal(body.summary.elapsed_source, 'client_script');
1990
  assert.equal(body.summary.server_elapsed_ms, 4321);
 
2677
  }
2678
  });
2679
 
2680
+ it('enriches failed Agent edit summaries with Agent state diagnostics', async () => {
2681
+ const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-edit-diagnostics-'));
2682
+ try {
2683
+ const imagePath = join(tempRoot, 'source.png');
2684
+ writeFileSync(imagePath, fakePngBuffer(2, 1));
2685
+ const requests = [];
2686
+
2687
+ await withServer(
2688
+ async (request, response) => {
2689
+ requests.push({ method: request.method, url: request.url });
2690
+ if (request.url === '/api/agent/capabilities') {
2691
+ response.writeHead(200, { 'content-type': 'application/json' });
2692
+ response.end(
2693
+ JSON.stringify({
2694
+ agent_streaming: {
2695
+ page_sse: { supported: true, endpoint: '/api/images' }
2696
+ }
2697
+ })
2698
+ );
2699
+ return;
2700
+ }
2701
+ if (request.url === '/api/agent/images/edit') {
2702
+ await readRequestText(request);
2703
+ response.writeHead(500, { 'content-type': 'application/json' });
2704
+ response.end(JSON.stringify({ error: { code: 'unexpected_error', message: 'edit failed', retryable: false } }));
2705
+ return;
2706
+ }
2707
+ if (request.url === '/api/agent/diagnostics/requests?idempotency_key=diag-edit-key') {
2708
+ response.writeHead(200, { 'content-type': 'application/json' });
2709
+ response.end(
2710
+ JSON.stringify({
2711
+ found: true,
2712
+ diagnostics: {
2713
+ request: {
2714
+ request_id: 'req_edit_diag',
2715
+ idempotency_key: 'diag-edit-key',
2716
+ status: 'failed'
2717
+ },
2718
+ error: {
2719
+ code: 'unexpected_error',
2720
+ retryable: false,
2721
+ diagnostics: {
2722
+ selected_channel_id: 'channel-edit',
2723
+ upstream_host: 'edit-upstream.example.test',
2724
+ transport_error_kind: 'socket_closed'
2725
+ }
2726
+ }
2727
+ }
2728
+ })
2729
+ );
2730
+ return;
2731
+ }
2732
+ response.writeHead(404, { 'content-type': 'application/json' });
2733
+ response.end(JSON.stringify({ error: 'missing' }));
2734
+ },
2735
+ async (baseUrl) => {
2736
+ const result = await runSkillScriptAsync(
2737
+ 'edit-image.mjs',
2738
+ [
2739
+ '--allow-billable',
2740
+ '--agent',
2741
+ '--idempotency-key',
2742
+ 'diag-edit-key',
2743
+ '--size',
2744
+ '3072x2048',
2745
+ imagePath,
2746
+ 'prompt'
2747
+ ],
2748
+ { GPT_IMAGE_PLAYGROUND_URL: baseUrl }
2749
+ );
2750
+
2751
+ assert.equal(result.status, 1);
2752
+ assert.equal(result.stdout.trim(), '');
2753
+ const body = JSON.parse(result.stderr);
2754
+ assert.equal(body.summary.request_id, 'req_edit_diag');
2755
+ assert.equal(body.summary.selected_channel_id, 'channel-edit');
2756
+ assert.equal(body.summary.upstream_host, 'edit-upstream.example.test');
2757
+ assert.equal(body.summary.transport_error_kind, 'socket_closed');
2758
+ assert.equal(body.summary.agent_diagnostics_checked, true);
2759
+ assert.equal(body.summary.agent_diagnostics_found, true);
2760
+ assert.equal(body.agent_failure_diagnostics.request_id, 'req_edit_diag');
2761
+ assert.deepEqual(
2762
+ requests.map((item) => `${item.method} ${item.url}`),
2763
+ [
2764
+ 'GET /api/agent/capabilities',
2765
+ 'POST /api/agent/images/edit',
2766
+ 'GET /api/agent/diagnostics/requests?idempotency_key=diag-edit-key'
2767
+ ]
2768
+ );
2769
+ }
2770
+ );
2771
+ } finally {
2772
+ rmSync(tempRoot, { recursive: true, force: true });
2773
+ }
2774
+ });
2775
+
2776
  it('rejects default WebP edit dry-runs when streaming is explicitly disabled', () => {
2777
  const result = runSkillScript('edit-image.mjs', [
2778
  '--size',
 
3199
  assert.match(skillText, /脚本参数仍写作 `--streaming-strategy responses-sse`/);
3200
  assert.match(skillText, /batch JSONL 字段是 `streaming_strategy`/);
3201
  assert.match(skillText, /`image_streaming_strategy` 是页面 form-data 字段名,不是 batch JSONL 字段/);
3202
+ assert.match(skillText, /`capabilities` 里声明的 `page_sse_supported=true`/);
3203
+ assert.match(skillText, /`selected_channel_id`、`upstream_host` 为空/);
3204
  assert.match(skillText, /支持位置参数 `<image-path> <prompt>`,也支持 `--image <path> <prompt>` 别名/);
3205
  assert.match(skillText, /`--image-backend responses-image-generation` 只用于页面 SSE edit/);
3206
  assert.match(skillText, /不要把 Matsca `limits\.partial_images=0\.\.4` 误套到 `responses-image-generation`/);
3207
  assert.match(apiReference, /limits\.partial_images_by_backend\[image_backend\]/);
3208
+ assert.match(apiReference, /只代表“声明支持”,不代表当前渠道每次实测都能成功/);
3209
+ assert.match(apiReference, /如果 `selected_channel_id`、`upstream_host` 为空/);
3210
  assert.match(apiReference, /Agent edit 不接收 `image_backend`、`output_format` 或 `output_compression`/);
3211
  assert.match(apiReference, /强制 Agent edit 时输出格式固定为 PNG,`partial_images` 按默认 Images API\/profile 范围校验/);
3212
  assert.match(apiReference, /图片路径可以用位置参数 `<image-path> <prompt>`,也可以用 `--image <path> <prompt>`/);
 
3244
  assert.match(readmeText, /npm run env:summary/);
3245
  assert.match(readmeText, /verification_scope\.mode=local_planning_only/);
3246
  assert.match(readmeText, /Hugging Face Space Secrets 只能写入和列出名称/);
3247
+ assert.match(readmeText, /`page_sse_supported=true` 只是声明支持,不代表实测一定成功/);
3248
 
3249
  assert.match(skillText, /不要手动并行启动多个单张脚本/);
3250
  assert.match(skillText, /capacity_feedback/);
 
3266
  assert.match(apiReference, /verification_scope\.mode=local_planning_only/);
3267
  assert.match(apiReference, /manifest_written=false/);
3268
  assert.match(apiReference, /Hugging Face Space Secrets 只能写入和列出名称/);
3269
+ assert.match(apiReference, /这个失败表示上游已生成但本地验收未通过/);
3270
+ assert.match(apiReference, /`validation_failure_count>0` 而 `request_failure_count=0`/);
3271
  });
3272
 
3273
  it('keeps WebUI page APIs out of the Agent OpenAPI contract', () => {
 
3322
  assert.match(openApiSource, /高分辨率 edit 默认优先使用页面端 \/api\/images form-data SSE/);
3323
  });
3324
 
3325
+ it('requires new probe and diagnostics work to keep API contracts ahead of Skill wrappers', () => {
3326
+ const readmeText = readFileSync(join(repoRoot, 'README.md'), 'utf8');
3327
+ const skillText = readFileSync(join(skillRoot, 'SKILL.md'), 'utf8');
3328
+ const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
3329
+
3330
+ assert.match(readmeText, /新增 probe、diagnostics 或路由可观测能力时,先落 API \/ capabilities \/ OpenAPI 契约/);
3331
+ assert.match(readmeText, /Skill 脚本做薄封装/);
3332
+ assert.match(skillText, /新增 probe、diagnostics、路由健康或请求旅程能力时/);
3333
+ assert.match(skillText, /GET \/api\/agent\/capabilities/);
3334
+ assert.match(skillText, /GET \/api\/agent\/openapi\.json/);
3335
+ assert.match(skillText, /\/api\/agent\/diagnostics\/\*/);
3336
+ assert.match(skillText, /Skill 脚本只做薄封装/);
3337
+ assert.match(skillText, /不能复制页面 API、运行态 API 和 Agent API 的边界判断/);
3338
+ assert.match(apiReference, /新增 probe、diagnostics 或健康摘要时/);
3339
+ assert.match(apiReference, /capabilities、OpenAPI 或明确的 Agent 只读端点/);
3340
+ assert.match(apiReference, /不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑/);
3341
+ });
3342
+
3343
  it('runs from a copied standalone skill directory outside the repository', () => {
3344
  const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-playground-agent-'));
3345
  const copiedSkillRoot = join(tempRoot, 'gpt-image-playground-agent');
 
3443
  assert.equal(body.manifest_write_reason, 'dry_run');
3444
  assert.equal(body.total, 2);
3445
  assert.equal(body.concurrency, 1);
3446
+ assert.equal(body.guardrails.ordered_prefix, 'demo');
3447
+ assert.equal(body.guardrails.repeat_ordered_prefix_on_real_run, true);
3448
+ assert.equal(body.guardrails.dimension_check_recommended, true);
3449
+ assert.match(body.guardrails.dimension_check_reason, /--dimension-check/);
3450
  assert.equal(body.tasks[0].endpoint, '/api/agent/images/generate');
3451
  assert.equal(body.tasks[0].idempotency_key, 'demo-0001-first-item');
3452
  assert.equal(body.tasks[0].request.model, 'gpt-image-2');
 
3467
  }
3468
  });
3469
 
3470
+ it('records enriched Agent diagnostics for failed batch tasks and manifests', async () => {
3471
+ const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-diagnostics-'));
3472
+ try {
3473
+ const inputPath = join(tempRoot, 'tasks.jsonl');
3474
+ const manifestPath = join(tempRoot, 'manifest.jsonl');
3475
+ writeFileSync(inputPath, JSON.stringify({ id: 'diag-task', prompt: 'prompt', idempotency_key: 'batch-diag-key' }));
3476
+
3477
+ await withServer(
3478
+ (request, response) => {
3479
+ if (request.url === '/api/agent/capabilities') {
3480
+ response.writeHead(200, { 'content-type': 'application/json' });
3481
+ response.end(JSON.stringify({ ok: true }));
3482
+ return;
3483
+ }
3484
+ if (request.url === '/api/agent/images/generate') {
3485
+ response.writeHead(500, { 'content-type': 'application/json' });
3486
+ response.end(JSON.stringify({ error: { code: 'unexpected_error', message: 'fetch failed', retryable: false } }));
3487
+ return;
3488
+ }
3489
+ if (request.url === '/api/agent/diagnostics/requests?idempotency_key=batch-diag-key') {
3490
+ response.writeHead(200, { 'content-type': 'application/json' });
3491
+ response.end(
3492
+ JSON.stringify({
3493
+ found: true,
3494
+ diagnostics: {
3495
+ request: {
3496
+ request_id: 'req_batch_diag',
3497
+ idempotency_key: 'batch-diag-key',
3498
+ status: 'failed'
3499
+ },
3500
+ error: {
3501
+ code: 'unexpected_error',
3502
+ retryable: false,
3503
+ diagnostics: {
3504
+ selected_channel_id: 'channel-b',
3505
+ upstream_host: 'batch-upstream.example.test'
3506
+ }
3507
+ }
3508
+ }
3509
+ })
3510
+ );
3511
+ return;
3512
+ }
3513
+ response.writeHead(404, { 'content-type': 'application/json' });
3514
+ response.end(JSON.stringify({ error: 'missing' }));
3515
+ },
3516
+ async (baseUrl) => {
3517
+ const result = await runSkillScriptAsync(
3518
+ 'batch-images.mjs',
3519
+ ['--allow-billable', '--input', inputPath, '--manifest', manifestPath, '--max-attempts', '1'],
3520
+ { GPT_IMAGE_PLAYGROUND_URL: baseUrl }
3521
+ );
3522
+
3523
+ assert.equal(result.status, 1);
3524
+ assert.equal(result.stderr.trim(), '');
3525
+ const body = JSON.parse(result.stdout);
3526
+ assert.equal(body.results[0].summary.request_id, 'req_batch_diag');
3527
+ assert.equal(body.results[0].summary.selected_channel_id, 'channel-b');
3528
+ assert.equal(body.results[0].summary.upstream_host, 'batch-upstream.example.test');
3529
+ assert.equal(body.results[0].summary.agent_diagnostics_checked, true);
3530
+ assert.equal(body.results[0].agent_failure_diagnostics.request_id, 'req_batch_diag');
3531
+ const manifestLines = readFileSync(manifestPath, 'utf8').trim().split(/\r?\n/).map(JSON.parse);
3532
+ assert.equal(manifestLines[0].summary.request_id, 'req_batch_diag');
3533
+ assert.equal(manifestLines[0].agent_failure_diagnostics.request_id, 'req_batch_diag');
3534
+ }
3535
+ );
3536
+ } finally {
3537
+ rmSync(tempRoot, { recursive: true, force: true });
3538
+ }
3539
+ });
3540
+
3541
  it('rejects invalid batch concurrency before dry-run output', () => {
3542
  const tempRoot = mkdtempSync(join(tmpdir(), 'gpt-image-batch-concurrency-'));
3543
  try {
 
4994
  assert.equal(result.stderr.trim(), '');
4995
  const body = JSON.parse(result.stdout);
4996
  assert.equal(body.results[0].status, 'succeeded');
4997
+ assert.deepEqual(body.results[0].summary.actual_dimensions, { width: 1024, height: 1024 });
4998
+ assert.deepEqual(body.results[0].summary.image_dimensions, [{ width: 1024, height: 1024 }]);
4999
  }
5000
  );
5001
  } finally {
 
5435
  }
5436
  if (request.url === '/api/agent/images/generate') {
5437
  response.writeHead(200, { 'content-type': 'application/json' });
5438
+ response.end(
5439
+ JSON.stringify({
5440
+ images: [
5441
+ { id: 'dim-image-bad', filename: 'dim-bad.png', b64_json: fakePngBase64(512, 512) },
5442
+ { id: 'dim-image-ok', filename: 'dim-ok.png', b64_json: fakePngBase64(1024, 1024) }
5443
+ ]
5444
+ })
5445
+ );
5446
  return;
5447
  }
5448
  response.writeHead(404, { 'content-type': 'application/json' });
 
5459
  assert.equal(result.stderr.trim(), '');
5460
  const body = JSON.parse(result.stdout);
5461
  assert.equal(body.ok, false);
5462
+ assert.equal(body.results[0].error.code, 'dimension_check_failed');
5463
+ assert.match(body.results[0].error.message, /尺寸校验失败/);
5464
+ assert.deepEqual(body.results[0].error.expected_dimensions, { width: 1024, height: 1024 });
5465
+ assert.deepEqual(body.results[0].error.actual_dimensions, { width: 512, height: 512 });
5466
+ assert.equal(body.results[0].validation_failure_kind, 'generated_artifact_failed_dimension_check');
5467
+ assert.equal(body.results[0].response.images[0].b64_json, undefined);
5468
+ assert.equal(body.results[0].response.images[0].b64_json_length, fakePngBase64(512, 512).length);
5469
+ assert.deepEqual(body.results[0].response.images[0].dimensions, { width: 512, height: 512 });
5470
+ assert.equal(body.results[0].response.images[1].b64_json, undefined);
5471
+ assert.equal(body.results[0].response.images[1].b64_json_length, fakePngBase64(1024, 1024).length);
5472
+ assert.deepEqual(body.results[0].response.images[1].dimensions, { width: 1024, height: 1024 });
5473
+ assert.deepEqual(body.results[0].summary.artifact_ids, ['dim-image-bad', 'dim-image-ok']);
5474
+ assert.deepEqual(body.results[0].summary.image_dimensions, [
5475
+ { width: 512, height: 512 },
5476
+ { width: 1024, height: 1024 }
5477
+ ]);
5478
+ assert.deepEqual(body.results[0].summary.expected_dimensions, { width: 1024, height: 1024 });
5479
+ assert.deepEqual(body.results[0].summary.actual_dimensions, { width: 512, height: 512 });
5480
+ assert.equal(body.results[0].summary.dimension_check_failed, true);
5481
+ assert.equal(body.failure_summary.validation_failure_count, 1);
5482
+ assert.equal(body.failure_summary.request_failure_count, 0);
5483
+ assert.equal(body.failure_summary.tasks[0].failure_kind, 'generated_artifact_failed_dimension_check');
5484
+ assert.deepEqual(body.failure_summary.tasks[0].artifact_ids, ['dim-image-bad', 'dim-image-ok']);
5485
  }
5486
  );
5487
  } finally {
 
6237
 
6238
  function runSkillScriptAsync(filename, args, env = {}, options = {}) {
6239
  return new Promise((resolve) => {
6240
+ const baseEnv = buildIsolatedSkillScriptEnv({ loadPrivateAgentEnv: options.loadPrivateAgentEnv });
6241
  const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], {
6242
  cwd: repoRoot,
6243
+ env: { ...baseEnv, ...env },
6244
  stdio: ['ignore', 'pipe', 'pipe']
6245
  });
6246
  let stdout = '';
scripts/command-center.test.mjs CHANGED
@@ -507,6 +507,11 @@ describe('Command center scripts', () => {
507
  );
508
  assert.equal(report.service.capabilities.page_sse_auth_required, true);
509
  assert.equal(report.service.capabilities.page_sse_auth_form_field, 'passwordHash');
 
 
 
 
 
510
  assert.equal(
511
  report.checks.find((check) => check.name === 'agent_auth_available_to_process')
512
  .auth_in_private_env_file,
@@ -535,6 +540,7 @@ describe('Command center scripts', () => {
535
  assert.match(text, /当前进程鉴权:未加载/);
536
  assert.match(text, /私有 Agent env:不存在/);
537
  assert.match(text, /下一步:/);
 
538
  assert.doesNotMatch(text, /^\{/);
539
  });
540
 
@@ -718,12 +724,22 @@ describe('Command center scripts', () => {
718
  assert.equal(body.summary.runtime, 'ok');
719
  assert.equal(body.summary.state_backend, 'memory');
720
  assert.equal(body.summary.page_sse_auth_ready, false);
 
 
721
  assert.equal(body.summary.responses_gpt2image_ready, true);
 
722
  assert.equal(body.summary.billable_smoke, 'skipped');
723
  assert.equal(body.layers.find((layer) => layer.name === 'billable_smoke').skipped, true);
 
 
 
 
 
724
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
 
725
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_required, true);
726
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_ready, false);
 
727
  assert.match(
728
  body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_next_action,
729
  /GPT_IMAGE_APP_PASSWORD_HASH/
@@ -798,6 +814,19 @@ describe('Command center scripts', () => {
798
  }
799
  return;
800
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
801
  if (request.url === '/api/agent/jobs/images/generate') {
802
  response.writeHead(400, { 'content-type': 'application/json' });
803
  response.end(
@@ -824,7 +853,9 @@ describe('Command center scripts', () => {
824
  const body = parseJsonPayload(result.stdout, 'agent doctor');
825
  assert.equal(body.service_base_url_source, 'user_provided');
826
  assert.equal(body.interactive_confirmation_required, false);
 
827
  assert.ok(hits.includes('/api/agent/images/generate'));
 
828
  }
829
  );
830
  });
 
507
  );
508
  assert.equal(report.service.capabilities.page_sse_auth_required, true);
509
  assert.equal(report.service.capabilities.page_sse_auth_form_field, 'passwordHash');
510
+ assert.equal(report.service.capabilities.page_sse_declared_supported, true);
511
+ assert.equal(report.service.capabilities.page_sse_real_smoke, 'not_run_by_first_run');
512
+ assert.equal(report.service.capabilities.responses_image_backend_real_smoke, 'not_run_by_first_run');
513
+ assert.match(formatFirstRunText(report), /页面 SSE:声明支持,实测=未执行真实 smoke/);
514
+ assert.match(formatFirstRunText(report), /Responses 后端:声明未支持,启用=否,实测=未执行真实 smoke/);
515
  assert.equal(
516
  report.checks.find((check) => check.name === 'agent_auth_available_to_process')
517
  .auth_in_private_env_file,
 
540
  assert.match(text, /当前进程鉴权:未加载/);
541
  assert.match(text, /私有 Agent env:不存在/);
542
  assert.match(text, /下一步:/);
543
+ assert.doesNotMatch(text, /实测=passed/);
544
  assert.doesNotMatch(text, /^\{/);
545
  });
546
 
 
724
  assert.equal(body.summary.runtime, 'ok');
725
  assert.equal(body.summary.state_backend, 'memory');
726
  assert.equal(body.summary.page_sse_auth_ready, false);
727
+ assert.equal(body.summary.page_sse_declared_supported, true);
728
+ assert.equal(body.summary.page_sse_real_smoke, 'skipped');
729
  assert.equal(body.summary.responses_gpt2image_ready, true);
730
+ assert.equal(body.summary.responses_image_backend_declared_supported, true);
731
  assert.equal(body.summary.billable_smoke, 'skipped');
732
  assert.equal(body.layers.find((layer) => layer.name === 'billable_smoke').skipped, true);
733
+ assert.ok(
734
+ body.layers
735
+ .find((layer) => layer.name === 'billable_smoke')
736
+ .checks.some((check) => check.name === 'responses_page_sse_generate_1k' && check.skipped === true)
737
+ );
738
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
739
+ assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_declared_supported, true);
740
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_required, true);
741
  assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_ready, false);
742
+ assert.equal(body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').declared_supported, true);
743
  assert.match(
744
  body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_next_action,
745
  /GPT_IMAGE_APP_PASSWORD_HASH/
 
814
  }
815
  return;
816
  }
817
+ if (request.url === '/api/images') {
818
+ response.writeHead(200, { 'content-type': 'text/event-stream' });
819
+ response.end(
820
+ [
821
+ 'data: {"type":"completed","filename":"doctor-page-sse.png","path":"/api/image/doctor-page-sse.png","output_format":"png"}',
822
+ '',
823
+ 'data: {"type":"done","client_request_id":"doctor-page-sse-request","images":[{"filename":"doctor-page-sse.png"}]}',
824
+ '',
825
+ ''
826
+ ].join('\n')
827
+ );
828
+ return;
829
+ }
830
  if (request.url === '/api/agent/jobs/images/generate') {
831
  response.writeHead(400, { 'content-type': 'application/json' });
832
  response.end(
 
853
  const body = parseJsonPayload(result.stdout, 'agent doctor');
854
  assert.equal(body.service_base_url_source, 'user_provided');
855
  assert.equal(body.interactive_confirmation_required, false);
856
+ assert.equal(body.summary.page_sse_real_smoke, 'passed');
857
  assert.ok(hits.includes('/api/agent/images/generate'));
858
+ assert.ok(hits.includes('/api/images'));
859
  }
860
  );
861
  });
scripts/first-run.mjs CHANGED
@@ -317,6 +317,7 @@ function summarizeService(service) {
317
  }
318
 
319
  function summarizeCapabilitiesBody(body) {
 
320
  return {
321
  auth_required: body?.auth?.required === true,
322
  auth_schemes: Array.isArray(body?.auth?.schemes) ? body.auth.schemes : [],
@@ -326,8 +327,12 @@ function summarizeCapabilitiesBody(body) {
326
  image_storage_mode: body?.storage?.image_storage_mode,
327
  agent_jobs_supported: body?.agent_jobs?.supported === true,
328
  page_sse_supported: body?.agent_streaming?.page_sse?.supported === true,
329
- responses_image_backend_enabled:
330
- body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true
 
 
 
 
331
  };
332
  }
333
 
@@ -412,6 +417,12 @@ export function formatFirstRunText(report) {
412
  lines.push(
413
  `- 页面 SSE 鉴权:${capability.page_sse_auth_required ? `需要 ${capability.page_sse_auth_form_field || 'passwordHash'}` : '不需要'}`
414
  );
 
 
 
 
 
 
415
  lines.push(`- 状态后端:${capability.state_backend || '未知'},图片存储:${capability.image_storage_mode || '未知'}`);
416
  }
417
  const runtime = report.service?.runtime || {};
@@ -444,6 +455,16 @@ function formatEndpoint(endpoint) {
444
  return `失败${endpoint.status ? ` ${endpoint.status}` : ''}${endpoint.error ? ` ${endpoint.error}` : ''}`;
445
  }
446
 
 
 
 
 
 
 
 
 
 
 
447
  function formatAuthState(auth) {
448
  if (!auth) return '未知';
449
  if (auth.has_token) return '已加载 token';
 
317
  }
318
 
319
  function summarizeCapabilitiesBody(body) {
320
+ const responsesRequirement = body?.supported?.image_backend_requirements?.['responses-image-generation'];
321
  return {
322
  auth_required: body?.auth?.required === true,
323
  auth_schemes: Array.isArray(body?.auth?.schemes) ? body.auth.schemes : [],
 
327
  image_storage_mode: body?.storage?.image_storage_mode,
328
  agent_jobs_supported: body?.agent_jobs?.supported === true,
329
  page_sse_supported: body?.agent_streaming?.page_sse?.supported === true,
330
+ page_sse_declared_supported: body?.agent_streaming?.page_sse?.supported === true,
331
+ page_sse_real_smoke: 'not_run_by_first_run',
332
+ upstream_sse_declared_supported: body?.agent_streaming?.upstream_sse?.supported === true,
333
+ responses_image_backend_declared_supported: responsesRequirement?.supported === true,
334
+ responses_image_backend_enabled: responsesRequirement?.enabled === true,
335
+ responses_image_backend_real_smoke: 'not_run_by_first_run'
336
  };
337
  }
338
 
 
417
  lines.push(
418
  `- 页面 SSE 鉴权:${capability.page_sse_auth_required ? `需要 ${capability.page_sse_auth_form_field || 'passwordHash'}` : '不需要'}`
419
  );
420
+ lines.push(
421
+ `- 页面 SSE:声明${capability.page_sse_declared_supported ? '支持' : '未支持'},实测=${formatSmokeState(capability.page_sse_real_smoke)}`
422
+ );
423
+ lines.push(
424
+ `- Responses 后端:声明${capability.responses_image_backend_declared_supported ? '支持' : '未支持'},启用=${capability.responses_image_backend_enabled ? '是' : '否'},实测=${formatSmokeState(capability.responses_image_backend_real_smoke)}`
425
+ );
426
  lines.push(`- 状态后端:${capability.state_backend || '未知'},图片存储:${capability.image_storage_mode || '未知'}`);
427
  }
428
  const runtime = report.service?.runtime || {};
 
455
  return `失败${endpoint.status ? ` ${endpoint.status}` : ''}${endpoint.error ? ` ${endpoint.error}` : ''}`;
456
  }
457
 
458
+ function formatSmokeState(value) {
459
+ const states = {
460
+ not_run_by_first_run: '未执行真实 smoke',
461
+ skipped: '已跳过',
462
+ passed: '通过',
463
+ failed: '失败'
464
+ };
465
+ return states[value] || value || '未知';
466
+ }
467
+
468
  function formatAuthState(auth) {
469
  if (!auth) return '未知';
470
  if (auth.has_token) return '已加载 token';
skills/gpt-image-playground-agent/SKILL.md CHANGED
@@ -20,7 +20,8 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
20
  - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
21
  - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
22
  - 先用 dry-run 或 `--contract-check` 检查请求、路由和鉴权;只有用户明确允许真实计费时才加 `--allow-billable`。
23
- - 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游 host、脱敏请求头、重试和下一步动作;不要再先手查 SQLite、Docker logs 或上游后台。
 
24
 
25
  ## 产品边界
26
 
@@ -32,12 +33,13 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
32
 
33
  - 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。
34
  - 默认 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` 或下载后校验。
 
35
  - 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
36
  - 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。
37
  - 复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时、连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩 prompt 或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。
38
  - 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
39
  - 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。
40
- - 单张文生图使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时走页面端 `/api/images` SSE,因为这些是页面高级字段,不属于 Agent JSON schema。`--responses-model` 必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`;显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 会被脚本前置拒绝。
41
  - 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
42
 
43
  ## 执行流程
@@ -55,7 +57,7 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
55
  11. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
56
  12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复、显式页面参数或明确诊断后切换。
57
  13. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。
58
- 14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。
59
  15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。
60
  16. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复用的 `Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`。
61
 
@@ -112,7 +114,7 @@ Authorization: Bearer <token>
112
  - `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
113
  - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
114
 
115
- 生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成和编辑 dry-run 还会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable` 和 `next_action`。
116
  所有生成、编辑、批量和探针脚本在 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 契约。
117
 
118
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
@@ -197,7 +199,7 @@ node "<skill-root>/scripts/batch-images.mjs" --base-url https://your-space.hf.sp
197
  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
198
  ```
199
 
200
- `--manifest` 使用 JSONL append-only 记录每条任务的 `index`、`id`、`idempotency_key`、`attempt`、`status`、响应或错误以及机器可读 `summary`;`--resume` 会读取已成功记录并跳过同一 `id` 或 `idempotency_key`。dry-run 不写 manifest,输出会声明 `manifest_written=false``manifest_write_reason=dry_run`。`--dimension-check` 会读取响应里的 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸是否等于任务 `size`。`--max-attempts` 会为第二次及以后尝试追加新的 attempt 级 idempotency key,避免复用终态失败 key;`--concurrency` 大于 `1` 时会先读取运行态并发建议,并发执行任务并按输入顺序输出结果。服务端 `recommendedConcurrency` 或 `channelQueue.capacityPerCredential` 小于请求值时,脚本会把有效并发降到建议值并在输出中写入 `capacity_feedback`;不要再另开多个单张脚本绕过这个限制。`--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`,便于区分上游未给终图和解析/断流问题。
201
 
202
  批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
203
 
 
20
  - 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
21
  - 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
22
  - 先用 dry-run 或 `--contract-check` 检查请求、路由和鉴权;只有用户明确允许真实计费时才加 `--allow-billable`。
23
+ - 真实调用成功或失败后,优先读取脚本输出的 `summary`。它是面向 Agent 的机器摘要,包含 `billable`、请求 ID、幂等键、产物 URL、耗时、耗时拆分、路由、渠道、上游 host、脱敏请求头、重试和下一步动作;Agent JSON 失败时脚本会按幂等键做一次只读 Agent state 诊断补采样,补充 `agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status`、`request_id`、渠道和上游 host。不要再先手查 SQLite、Docker logs 或上游后台。
24
+ - 新增 probe、diagnostics、路由健康或请求旅程能力时,先在服务端定义机器 API 契约,并通过 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json` 或明确的 `/api/agent/diagnostics/*` 端点声明;Skill 脚本只做薄封装,不能复制页面 API、运行态 API 和 Agent API 的边界判断。
25
 
26
  ## 产品边界
27
 
 
33
 
34
  - 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。
35
  - 默认 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` 或下载后校验。
36
+ - `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE 或 Responses 路径返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式切换到 Agent JSON 或 job,不自动回退。
37
  - 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
38
  - 真实批量并发前先看 `GET /api/runtime-capabilities` 的 `channelQueue.capacityPerCredential` 和 `streamingBatch.recommendedConcurrency`。如果服务端建议并发为 `1`,或返回 `channel_capacity_queue_aborted` / `retry_after_seconds`,同一渠道任务保持 `--concurrency 1`,不要用多个 shell 进程绕过限流。
39
  - 复杂 UI、长 prompt、高质量图生图遇到 5 分钟级超时、连接中断或上游 503 时,不要把失败归因到提示词质量;先读 `summary` 和诊断,再用新 key 显式尝试压缩 prompt 或改为 `quality=medium` 的对照请求,并记录这是稳定性取舍。
40
  - 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
41
  - 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。
42
+ - 单张文生图使用 `--responses-model`/`--gpt-model`、`--thinking`、`--prompt-optimization` 或 `--force-web` 时走页面端 `/api/images` SSE,因为这些是页面高级字段,不属于 Agent JSON schema。`--responses-model` 覆盖本次请求的 Responses 顶层模型;未传时使用服务端 `OPENAI_RESPONSES_API_MODEL`。该字段只影响本项目 `responses-image-generation` 路径,不改变兼容上游自身 Images API 桥接层内部选择的模型。`--responses-model` 必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`;显式 `--agent`、`--job`、`stream_mode=non_stream` 或 `streaming_strategy=off` 会被脚本前置拒绝。
43
  - 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
44
 
45
  ## 执行流程
 
57
  11. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
58
  12. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复、显式页面参数或明确诊断后切换。
59
  13. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。
60
+ 14. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。页面 SSE 返回 `503`、断流,或 `summary` 里的 `selected_channel_id`、`upstream_host` 为空时,先按结构化失败诊断,再用新 key 显式换路径,不要把它当成已自动回退成功。
61
  15. 返回结果时优先给出 `summary`、`content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。回答“4K 非流式花了多久”时优先读 `summary.elapsed_ms`,服务端返回 timing 时也读 `summary.server_elapsed_ms`。
62
  16. 需要查询页面请求后的人工反馈或日志摘要时,使用页面 SSE 的 `clientRequestId` 或脚本复用的 `Idempotency-Key` 调用 `scripts/diagnose-request.mjs --client-request-id ...`;不要直接调用 `/api/logs`。需要查询 Agent state 请求状态时,使用 `scripts/diagnose-request.mjs --agent-request-id ...` 或 `--idempotency-key ...`。
63
 
 
114
  - `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
115
  - `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
116
 
117
+ 生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成和编辑 dry-run 还会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_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`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `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`,便于人工审查
118
  所有生成、编辑、批量和探针脚本在 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 契约。
119
 
120
  如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
 
199
  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
200
  ```
201
 
202
+ `--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`,便于区分上游未给终图和解析/断流问题。
203
 
204
  批量 JSONL 字段按模式区分:`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段。
205
 
skills/gpt-image-playground-agent/references/api.md CHANGED
@@ -47,7 +47,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
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`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。失败摘要也稳定包含空数组或 `null` 形式的产物、路由渠道字段,便于 subagent 按同一模板汇报。失败摘要还包含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable` 和 `next_action`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。
51
 
52
  生成脚本参数:
53
 
@@ -59,7 +59,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
59
  - `--output-compression`:默认 `100`,仅适用于 `jpeg` 或 `webp`。
60
  - `--response-mode`:默认 `path`。
61
  - `--image-backend`:可选,显式选择 `images-api`、`images`、`responses` 或 `responses-image-generation`。
62
- - `--responses-model` / `--gpt-model`:页面 SSE 专属字段,设置 Responses 顶层模型;必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`。
63
  - `--thinking`:页面 SSE 专属字段,可选值为 `minimal`、`none`、`low`、`medium`、`high` 或 `xhigh`。
64
  - `--prompt-optimization`:页面 SSE 专属字段,必须是 `true` 或 `false`。
65
  - `--force-web`:页面 SSE 专属字段,会发送为 form-data `force_web=true`。
@@ -115,7 +115,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
115
  - `--manifest`:append-only JSONL manifest 路径,默认 `<input>.manifest.jsonl`。
116
  - `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。
117
  - `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
118
- - `--dimension-check`:读取响应 `b64_json` 或同 origin `content_url`,校验 PNG/JPEG/WebP 尺寸等于任务 `size`。
119
  - `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
120
  - `--concurrency`:并发执行窗口,默认 `1`。大于 `1` 时会先读取 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`,把有效并发限制到服务端建议值后按输入顺序输出结果;适合已确认渠道容量的批量生产。
121
  - `--max-consecutive-failures`:顺序执行下的连续失败熔断阈值,默认 `0` 表示不熔断。只能与 `--concurrency 1` 同用。
@@ -123,7 +123,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
123
  - `--dry-run`
124
  - `--allow-billable`
125
 
126
- 批量 dry-run 不写 manifest,输出会声明 `manifest_written=false` 和 `manifest_write_reason=dry_run`。只有真实执行时 manifest 才作为 append-only 续跑记录写入。
127
 
128
  批量 JSONL 每行字段按 `mode` 区分。`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 会选择页面 SSE 路径,且必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`,因为 Agent JSON 不接收请求级 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
129
 
@@ -191,7 +191,7 @@ GET /api/agent/capabilities
191
  - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
192
  - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
193
  - `agent_streaming.upstream_sse`:Agent generate/edit 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。
194
- - `agent_streaming.upstream_sse.supported`:布尔值;当服务端支持 Agent 内部上游 SSE 消费时为 `true`,否则为 `false`。客户端只在为 `true` 时发送上游流式控制字段。
195
  - `agent_streaming.upstream_sse.request_fields`:兼容旧客户端的字段合集,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。
196
  - `agent_streaming.upstream_sse.request_fields_by_mode.generate`:generate 可发送的上游 SSE 控制字段,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。
197
  - `agent_streaming.upstream_sse.request_fields_by_mode.edit`:edit 可发送的上游 SSE 控制字段,当前为 `stream_mode`、`streaming_strategy`、`partial_images`。
@@ -200,19 +200,22 @@ GET /api/agent/capabilities
200
  - `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
201
  - `agent_streaming.upstream_sse.stream_modes`:支持 `auto`、`stream`、`non_stream`。
202
  - `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前包含 `auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
203
- - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
204
  - `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
205
  - `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
 
206
  - `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
207
  - `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
208
  - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
209
  - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
210
  - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
211
  - `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。
212
- - `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径。
213
  - `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
214
  - 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
215
  - `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
 
 
216
  - `defaults.image_backend`:Agent generate 默认 `images-api`。
217
  - `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。
218
  - `defaults.streaming_strategy`:Agent generate 默认 `auto`。
@@ -673,7 +676,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
673
  }
674
  ```
675
 
676
- `diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。
677
 
678
  常见错误码:
679
 
 
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`、`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`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。失败摘要也稳定包含空数组或 `null` 形式的产物、路由渠道和尺寸字段,便于 subagent 按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `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`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。
51
 
52
  生成脚本参数:
53
 
 
59
  - `--output-compression`:默认 `100`,仅适用于 `jpeg` 或 `webp`。
60
  - `--response-mode`:默认 `path`。
61
  - `--image-backend`:可选,显式选择 `images-api`、`images`、`responses` 或 `responses-image-generation`。
62
+ - `--responses-model` / `--gpt-model`:页面 SSE 专属字段,覆盖本次请求的 Responses 顶层模型;未传时使用服务端 `OPENAI_RESPONSES_API_MODEL`。必须同时设置 `--image-backend responses-image-generation` 或兼容别名 `responses`。该字段只影响本项目的 `responses-image-generation` 路径,不改变兼容上游自身 Images API 桥接层内部选择的模型。
63
  - `--thinking`:页面 SSE 专属字段,可选值为 `minimal`、`none`、`low`、`medium`、`high` 或 `xhigh`。
64
  - `--prompt-optimization`:页面 SSE 专属字段,必须是 `true` 或 `false`。
65
  - `--force-web`:页面 SSE 专属字段,会发送为 form-data `force_web=true`。
 
115
  - `--manifest`:append-only JSONL manifest 路径,默认 `<input>.manifest.jsonl`。
116
  - `--resume`:读取 manifest 中已 `succeeded` 的 `id` 或 `idempotency_key` 并跳过。
117
  - `--ordered-prefix`:未显式提供 `idempotency_key` 时构造稳定有序 key 的前缀,默认 `batch`。
118
+ - `--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`这个失败表示上游已生成但本地验收未通过,不等于上游请求失败。
119
  - `--max-attempts`:失败任务最大尝试次数。第二次及后续尝试会追加新的 attempt 级 `Idempotency-Key`,避免复用终态失败 key。
120
  - `--concurrency`:并发执行窗口,默认 `1`。大于 `1` 时会先读取 `/api/runtime-capabilities` 的 `streamingBatch.recommendedConcurrency` 和 `channelQueue.capacityPerCredential`,把有效并发限制到服务端建议值后按输入顺序输出结果;适合已确认渠道容量的批量生产。
121
  - `--max-consecutive-failures`:顺序执行下的连续失败熔断阈值,默认 `0` 表示不熔断。只能与 `--concurrency 1` 同用。
 
123
  - `--dry-run`
124
  - `--allow-billable`
125
 
126
+ 批量 dry-run 不写 manifest,输出会声明 `manifest_written=false`、`manifest_write_reason=dry_run` 和 `guardrails`。`guardrails.ordered_prefix` 是本次 dry-run 用于自动生成幂等键的前缀,真实执行应复用同一个 `--ordered-prefix`;`guardrails.dimension_check_recommended=true` 表示输入包含固定尺寸但未启用 `--dimension-check`。只有真实执行时 manifest 才作为 append-only 续跑记录写入;Agent JSON 失败时 manifest 会同时保存增强后的 `summary` 和 `agent_failure_diagnostics`尺寸门禁失败同样写入结构化 summary 和可审查产物 URL,避免只能从中文错误文本解析期望和实际尺寸。批量总摘要会输出 `failure_summary.validation_failure_count` 和 `failure_summary.request_failure_count`,用于区分“上游已生成但本地验收失败”和“请求未成功完成”。当 `validation_failure_count>0` 而 `request_failure_count=0` 时,要按验收失败处理,不能当成上游不可用。
127
 
128
  批量 JSONL 每行字段按 `mode` 区分。`background` 只适用于 `generate`;`image_path`、`image_paths`、`mask_path` 只适用于 `edit`。默认 WebP edit 任务走页面 SSE;如需 Agent edit 固定输出,请拆成单张 `edit-image.mjs --agent`。`output_format`、`format`、`output_compression`、`moderation`、`image_backend`、`streaming_strategy`、`partial_images`、`responsesModel`/`gptModel`/`gpt_model`、`thinking`、`promptOptimization`/`prompt_optimization`、`force_web`/`forceWeb` 可用于页面 SSE 路径。edit 任务设置 `image_backend=responses-image-generation` 时会走页面 SSE;不要把它改成 Agent edit。`responsesModel` 会选择页面 SSE 路径,且必须同时设置 `image_backend=responses-image-generation` 或兼容值 `responses`,因为 Agent JSON 不接收请求级 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 阶段显式拒绝跨模式字段、未知字段和无效路由控制字段,避免参数被真实接口忽略。
129
 
 
191
  - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
192
  - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
193
  - `agent_streaming.upstream_sse`:Agent generate/edit 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。
194
+ - `agent_streaming.upstream_sse.supported`:布尔值;当服务端支持 Agent 内部上游 SSE 消费时为 `true`,否则为 `false`。客户端只在为 `true` 时发送上游流式控制字段。这个字段只代表“声明支持”,不代表当前渠道每次实测都能成功。
195
  - `agent_streaming.upstream_sse.request_fields`:兼容旧客户端的字段合集,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。
196
  - `agent_streaming.upstream_sse.request_fields_by_mode.generate`:generate 可发送的上游 SSE 控制字段,当前为 `image_backend`、`stream_mode`、`streaming_strategy`、`partial_images`。
197
  - `agent_streaming.upstream_sse.request_fields_by_mode.edit`:edit 可发送的上游 SSE 控制字段,当前为 `stream_mode`、`streaming_strategy`、`partial_images`。
 
200
  - `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
201
  - `agent_streaming.upstream_sse.stream_modes`:支持 `auto`、`stream`、`non_stream`。
202
  - `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前包含 `auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
203
+ - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。即使该字段为 `supported=true`,页面 SSE 仍可能在当前渠道返回 `503`、断流或没有选中渠道;这时应先诊断,再显式切换到 Agent JSON 或 job,不自动回退。
204
  - `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
205
  - `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
206
+ - 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
207
  - `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
208
  - `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
209
  - `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
210
  - `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
211
  - `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
212
  - `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。
213
+ - `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径;不要把页面 SSE 的失败解释成自动回退到 Agent JSON 成功
214
  - `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
215
  - 批量 JSONL 路由控制字段:`page_sse`、`complex_ui`、`long_image`、`resume_or_recover` 必须是 JSON 布尔值,`transport` 目前只接受 `page_sse`;脚本会在 dry-run 阶段拒绝字符串布尔值和未知 transport。
216
  - `GET /api/runtime-capabilities` 不属于 Agent capabilities。它是页面工作台读取的运行态能力摘要,用于展示流式默认值、图片上游传输配置、渠道健康、渠道队列、并发建议、Responses 后端 enablement 和缺失环境变量,不进入 Agent OpenAPI。
217
+
218
+ 新增 probe、diagnostics 或健康摘要时,先把机器契约放进 capabilities、OpenAPI 或明确的 Agent 只读端点,再让脚本消费这些字段;不要让脚本自己拼 page API、runtime API 和 Agent API 的边界逻辑。
219
  - `defaults.image_backend`:Agent generate 默认 `images-api`。
220
  - `defaults.stream_mode`:Agent generate 默认 `auto`。auto 会先尝试内部上游 SSE;无法产出最终图时显式回退并暴露可观测标记。
221
  - `defaults.streaming_strategy`:Agent generate 默认 `auto`。
 
676
  }
677
  ```
678
 
679
+ `diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。如果页面 SSE 请求返回 `page_sse_failed`、`503`、断流,且 `summary.selected_channel_id` 与 `summary.upstream_host` 为空,按页面流式路径未跑通处理;先用 `diagnose-request.mjs` 读取结构化摘要,再用新的 `Idempotency-Key` 显式选择 Agent JSON 或 job,不自动回退。
680
 
681
  常见错误码:
682
 
skills/gpt-image-playground-agent/scripts/batch-images.mjs CHANGED
@@ -1,5 +1,6 @@
1
  #!/usr/bin/env node
2
  import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs';
 
3
  import {
4
  errorMessage,
5
  assertValidImageSizeForModel,
@@ -25,6 +26,7 @@ import {
25
  postPageSse
26
  } from './lib/page-sse-client.mjs';
27
  import {
 
28
  buildFailureSummary as buildScriptFailureSummary,
29
  buildSuccessSummary,
30
  completeScriptTiming,
@@ -57,6 +59,7 @@ const MAX_IDEMPOTENCY_KEY_LENGTH = 200;
57
  const DEFAULT_BATCH_MAX_ATTEMPTS = 1;
58
  const DEFAULT_MAX_CONSECUTIVE_FAILURES = 0;
59
  const DEFAULT_BATCH_CONCURRENCY = 1;
 
60
  const GENERATE_ONLY_FIELDS = [
61
  'background'
62
  ];
@@ -79,6 +82,21 @@ const PAGE_ADVANCED_FIELDS = [
79
  const EDIT_ONLY_FIELDS = ['image_path', 'image_paths', 'mask_path'];
80
  const BOOLEAN_ROUTING_FIELDS = ['page_sse', 'complex_ui', 'long_image', 'resume_or_recover'];
81
  const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  const TASK_FIELDS = new Set([
83
  'id',
84
  'mode',
@@ -180,6 +198,7 @@ if (!options.allowBillable || options.dryRun) {
180
  max_attempts: options.maxAttempts,
181
  max_consecutive_failures: options.maxConsecutiveFailures,
182
  concurrency: options.concurrency,
 
183
  tasks: planned.map((task) => {
184
  const routing = buildTaskRouting(task);
185
  return {
@@ -672,13 +691,13 @@ async function runTask(task) {
672
  const routing = buildTaskRouting(task);
673
  const taskTiming = startScriptTiming();
674
  try {
675
- const response =
676
  routing.transport === 'page_sse'
677
  ? await postPageSseTask(task, routing)
678
  : task.mode === 'edit'
679
  ? await postEditTask(task)
680
  : await postGenerateTask(task);
681
- if (options.dimensionCheck) await assertDimensions(task, response);
682
  const summary = buildSuccessSummary({
683
  result: response,
684
  routing,
@@ -698,8 +717,27 @@ async function runTask(task) {
698
  billable: failure.billable !== false,
699
  nextAction: failure.next_step
700
  });
701
- const output = { ok: false, status: 'failed', id: task.id, idempotency_key: task.idempotencyKey, ...failure, summary };
702
- appendManifest(manifestPath, { ...baseManifestEntry(task), status: 'failed', ...failure, summary });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
703
  return output;
704
  }
705
  }
@@ -816,6 +854,22 @@ function buildTaskFailureOutput(error, routing) {
816
  next_step: error.pageSseFailure.next_step
817
  };
818
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
819
  return {
820
  ...(error?.billable === false ? { billable: false } : {}),
821
  error: errorMessage(error),
@@ -824,6 +878,10 @@ function buildTaskFailureOutput(error, routing) {
824
  };
825
  }
826
 
 
 
 
 
827
  function buildCircuitBreakerSkippedTask(task, consecutiveFailures) {
828
  return {
829
  status: 'skipped',
@@ -834,6 +892,22 @@ function buildCircuitBreakerSkippedTask(task, consecutiveFailures) {
834
  };
835
  }
836
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
837
  function buildFailedTaskSummary(result, task) {
838
  const error = normalizeFailureError(result.error);
839
  return {
@@ -844,12 +918,21 @@ function buildFailedTaskSummary(result, task) {
844
  route: result.routing?.transport,
845
  endpoint: result.routing?.endpoint,
846
  billable: result.billable !== false,
 
847
  code: error.code,
848
  message: error.message,
 
 
 
849
  next_step: result.next_step || buildFailureNextStep(error)
850
  };
851
  }
852
 
 
 
 
 
 
853
  function normalizeFailureError(error) {
854
  if (error && typeof error === 'object') {
855
  return {
@@ -871,6 +954,8 @@ function buildFailureSummary(failedTasks) {
871
  count: failedTasks.length,
872
  billable_count: failedTasks.filter((task) => task.billable).length,
873
  non_billable_count: failedTasks.filter((task) => !task.billable).length,
 
 
874
  tasks: failedTasks
875
  };
876
  }
@@ -1293,15 +1378,25 @@ function enrichImageUrls(result) {
1293
 
1294
  async function assertDimensions(task, response) {
1295
  const expected = parseExpectedSize(task.raw.size || (task.mode === 'generate' ? '1024x1024' : undefined));
1296
- if (!expected) throw new Error(`${task.id} --dimension-check 需要 size 为 WIDTHxHEIGHT。`);
1297
- if (!Array.isArray(response.images)) return;
 
 
1298
  for (const image of response.images) {
1299
  const bytes = await readImageBytes(image);
1300
  const actual = readImageDimensions(bytes);
 
1301
  if (actual.width !== expected.width || actual.height !== expected.height) {
1302
- throw new Error(`${task.id} 尺寸校验失败:期望 ${expected.width}x${expected.height},实际 ${actual.width}x${actual.height}。`);
1303
  }
1304
  }
 
 
 
 
 
 
 
1305
  }
1306
 
1307
  async function readImageBytes(image) {
 
1
  #!/usr/bin/env node
2
  import { AGENT_ENDPOINTS } from './lib/agent-api-paths.mjs';
3
+ import { enrichFailureWithAgentDiagnostics } from './lib/agent-diagnostics-summary.mjs';
4
  import {
5
  errorMessage,
6
  assertValidImageSizeForModel,
 
26
  postPageSse
27
  } from './lib/page-sse-client.mjs';
28
  import {
29
+ attachSummary,
30
  buildFailureSummary as buildScriptFailureSummary,
31
  buildSuccessSummary,
32
  completeScriptTiming,
 
59
  const DEFAULT_BATCH_MAX_ATTEMPTS = 1;
60
  const DEFAULT_MAX_CONSECUTIVE_FAILURES = 0;
61
  const DEFAULT_BATCH_CONCURRENCY = 1;
62
+ const FAILURE_KIND_DIMENSION_CHECK = 'generated_artifact_failed_dimension_check';
63
  const GENERATE_ONLY_FIELDS = [
64
  'background'
65
  ];
 
82
  const EDIT_ONLY_FIELDS = ['image_path', 'image_paths', 'mask_path'];
83
  const BOOLEAN_ROUTING_FIELDS = ['page_sse', 'complex_ui', 'long_image', 'resume_or_recover'];
84
  const THINKING_VALUES = new Set(['minimal', 'none', 'low', 'medium', 'high', 'xhigh']);
85
+
86
+ class DimensionCheckError extends Error {
87
+ constructor(message, details = {}) {
88
+ super(message);
89
+ this.name = 'DimensionCheckError';
90
+ this.code = 'dimension_check_failed';
91
+ this.retryable = false;
92
+ this.billable = true;
93
+ this.nextStep = '确认当前渠道是否支持请求尺寸,或调整任务接受实际返回尺寸;重新执行必须使用新的 Idempotency-Key。';
94
+ this.expectedDimensions = details.expected;
95
+ this.actualDimensions = details.actual;
96
+ this.response = sanitizeResponse(details.response);
97
+ }
98
+ }
99
+
100
  const TASK_FIELDS = new Set([
101
  'id',
102
  'mode',
 
198
  max_attempts: options.maxAttempts,
199
  max_consecutive_failures: options.maxConsecutiveFailures,
200
  concurrency: options.concurrency,
201
+ guardrails: buildDryRunGuardrails(planned, options),
202
  tasks: planned.map((task) => {
203
  const routing = buildTaskRouting(task);
204
  return {
 
691
  const routing = buildTaskRouting(task);
692
  const taskTiming = startScriptTiming();
693
  try {
694
+ let response =
695
  routing.transport === 'page_sse'
696
  ? await postPageSseTask(task, routing)
697
  : task.mode === 'edit'
698
  ? await postEditTask(task)
699
  : await postGenerateTask(task);
700
+ if (options.dimensionCheck) response = await assertDimensions(task, response);
701
  const summary = buildSuccessSummary({
702
  result: response,
703
  routing,
 
717
  billable: failure.billable !== false,
718
  nextAction: failure.next_step
719
  });
720
+ const failureOutput = { ok: false, status: 'failed', id: task.id, idempotency_key: task.idempotencyKey, ...failure, summary };
721
+ const enriched = shouldEnrichAgentFailure(failure)
722
+ ? await enrichFailureWithAgentDiagnostics({
723
+ baseUrl,
724
+ authHeaders,
725
+ idempotencyKey: task.idempotencyKey,
726
+ failureOutput,
727
+ summary,
728
+ timeoutMs
729
+ })
730
+ : { failureOutput, summary };
731
+ const output = attachSummary(enriched.failureOutput, enriched.summary);
732
+ appendManifest(manifestPath, {
733
+ ...baseManifestEntry(task),
734
+ status: 'failed',
735
+ ...failure,
736
+ ...(enriched.failureOutput.agent_failure_diagnostics
737
+ ? { agent_failure_diagnostics: enriched.failureOutput.agent_failure_diagnostics }
738
+ : {}),
739
+ summary: enriched.summary
740
+ });
741
  return output;
742
  }
743
  }
 
854
  next_step: error.pageSseFailure.next_step
855
  };
856
  }
857
+ if (error?.code === 'dimension_check_failed') {
858
+ return {
859
+ billable: error.billable !== false,
860
+ error: {
861
+ code: error.code,
862
+ message: error.message,
863
+ retryable: error.retryable,
864
+ expected_dimensions: error.expectedDimensions,
865
+ actual_dimensions: error.actualDimensions
866
+ },
867
+ validation_failure_kind: FAILURE_KIND_DIMENSION_CHECK,
868
+ response: error.response,
869
+ routing,
870
+ ...(typeof error?.nextStep === 'string' ? { next_step: error.nextStep } : {})
871
+ };
872
+ }
873
  return {
874
  ...(error?.billable === false ? { billable: false } : {}),
875
  error: errorMessage(error),
 
878
  };
879
  }
880
 
881
+ function shouldEnrichAgentFailure(failure) {
882
+ return failure.billable !== false && failure.routing?.transport === 'agent_json';
883
+ }
884
+
885
  function buildCircuitBreakerSkippedTask(task, consecutiveFailures) {
886
  return {
887
  status: 'skipped',
 
892
  };
893
  }
894
 
895
+ function buildDryRunGuardrails(plannedTasks, parsedOptions) {
896
+ const hasExplicitFixedSize = plannedTasks.some((task) => {
897
+ const size = task.raw?.size;
898
+ return typeof size === 'string' && size !== 'auto';
899
+ });
900
+ return {
901
+ ordered_prefix: parsedOptions.orderedPrefix,
902
+ repeat_ordered_prefix_on_real_run: true,
903
+ dimension_check_recommended: hasExplicitFixedSize && !parsedOptions.dimensionCheck,
904
+ dimension_check_reason:
905
+ hasExplicitFixedSize && !parsedOptions.dimensionCheck
906
+ ? '输入包含固定尺寸;真实上游可能返回非请求尺寸,尺寸敏感任务应添加 --dimension-check。'
907
+ : null
908
+ };
909
+ }
910
+
911
  function buildFailedTaskSummary(result, task) {
912
  const error = normalizeFailureError(result.error);
913
  return {
 
918
  route: result.routing?.transport,
919
  endpoint: result.routing?.endpoint,
920
  billable: result.billable !== false,
921
+ failure_kind: readFailureKind(result),
922
  code: error.code,
923
  message: error.message,
924
+ artifact_ids: Array.isArray(result.summary?.artifact_ids) ? result.summary.artifact_ids : [],
925
+ content_urls: Array.isArray(result.summary?.content_urls) ? result.summary.content_urls : [],
926
+ absolute_content_urls: Array.isArray(result.summary?.absolute_content_urls) ? result.summary.absolute_content_urls : [],
927
  next_step: result.next_step || buildFailureNextStep(error)
928
  };
929
  }
930
 
931
+ function readFailureKind(result) {
932
+ if (typeof result?.validation_failure_kind === 'string') return result.validation_failure_kind;
933
+ return 'request_failed';
934
+ }
935
+
936
  function normalizeFailureError(error) {
937
  if (error && typeof error === 'object') {
938
  return {
 
954
  count: failedTasks.length,
955
  billable_count: failedTasks.filter((task) => task.billable).length,
956
  non_billable_count: failedTasks.filter((task) => !task.billable).length,
957
+ validation_failure_count: failedTasks.filter((task) => task.failure_kind === FAILURE_KIND_DIMENSION_CHECK).length,
958
+ request_failure_count: failedTasks.filter((task) => task.failure_kind !== FAILURE_KIND_DIMENSION_CHECK).length,
959
  tasks: failedTasks
960
  };
961
  }
 
1378
 
1379
  async function assertDimensions(task, response) {
1380
  const expected = parseExpectedSize(task.raw.size || (task.mode === 'generate' ? '1024x1024' : undefined));
1381
+ if (!expected) throw new DimensionCheckError(`${task.id} --dimension-check 需要 size 为 WIDTHxHEIGHT。`);
1382
+ if (!Array.isArray(response.images)) return response;
1383
+ const images = [];
1384
+ let mismatch;
1385
  for (const image of response.images) {
1386
  const bytes = await readImageBytes(image);
1387
  const actual = readImageDimensions(bytes);
1388
+ images.push({ ...image, dimensions: actual });
1389
  if (actual.width !== expected.width || actual.height !== expected.height) {
1390
+ mismatch ??= actual;
1391
  }
1392
  }
1393
+ if (mismatch) {
1394
+ throw new DimensionCheckError(
1395
+ `${task.id} 尺寸校验失败:期望 ${expected.width}x${expected.height},实际 ${mismatch.width}x${mismatch.height}。`,
1396
+ { expected, actual: mismatch, response: { ...response, images } }
1397
+ );
1398
+ }
1399
+ return { ...response, images };
1400
  }
1401
 
1402
  async function readImageBytes(image) {
skills/gpt-image-playground-agent/scripts/edit-image.mjs CHANGED
@@ -33,6 +33,7 @@ import {
33
  normalizeImageBackendForPage,
34
  postPageSse
35
  } from './lib/page-sse-client.mjs';
 
36
 
37
  const STREAM_MODES = new Set(['auto', 'stream', 'non_stream']);
38
  const STREAMING_STRATEGIES = new Set([
@@ -668,6 +669,25 @@ if (routingGuidance.transport === 'page_sse') {
668
  }
669
  }
670
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
671
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
672
  let response;
673
  let result;
@@ -720,16 +740,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
720
  const failureOutput = { ...lastResult, retry_after: lastRetryAfter };
721
  console.error(
722
  JSON.stringify(
723
- attachSummary(
724
- failureOutput,
725
- buildFailureSummary({
726
- errorBody: failureOutput,
727
- routing: { transport: 'agent_json', endpoint: '/api/agent/images/edit' },
728
- timing: completeScriptTiming(scriptTiming),
729
- idempotencyKey,
730
- billable: failureOutput?.billable !== false
731
- })
732
- ),
733
  null,
734
  2
735
  )
 
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([
 
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;
 
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
  )
skills/gpt-image-playground-agent/scripts/generate-image.mjs CHANGED
@@ -1,5 +1,6 @@
1
  #!/usr/bin/env node
2
  import { AGENT_ENDPOINTS, buildAgentJobResultPath } from './lib/agent-api-paths.mjs';
 
3
  import {
4
  errorMessage,
5
  assertValidImageSizeForModel,
@@ -615,7 +616,10 @@ async function runGenerateRequest(options = {}) {
615
 
616
  console.error(
617
  JSON.stringify(
618
- buildFailureOutput({ ...lastResult, retry_after: lastRetryAfter }, { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate }),
 
 
 
619
  null,
620
  2
621
  )
@@ -1102,6 +1106,19 @@ function buildFailureOutput(output, routing) {
1102
  );
1103
  }
1104
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1105
  function buildPageSseFailureStatus(error) {
1106
  if (error && typeof error === 'object') {
1107
  if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
 
1
  #!/usr/bin/env node
2
  import { AGENT_ENDPOINTS, buildAgentJobResultPath } from './lib/agent-api-paths.mjs';
3
+ import { enrichFailureWithAgentDiagnostics } from './lib/agent-diagnostics-summary.mjs';
4
  import {
5
  errorMessage,
6
  assertValidImageSizeForModel,
 
616
 
617
  console.error(
618
  JSON.stringify(
619
+ await buildAgentFailureOutput(
620
+ { ...lastResult, retry_after: lastRetryAfter },
621
+ { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate }
622
+ ),
623
  null,
624
  2
625
  )
 
1106
  );
1107
  }
1108
 
1109
+ async function buildAgentFailureOutput(output, routing) {
1110
+ const failure = buildFailureOutput(output, routing);
1111
+ const enriched = await enrichFailureWithAgentDiagnostics({
1112
+ baseUrl,
1113
+ authHeaders,
1114
+ idempotencyKey,
1115
+ failureOutput: failure,
1116
+ summary: failure.summary,
1117
+ timeoutMs
1118
+ });
1119
+ return attachSummary(enriched.failureOutput, enriched.summary);
1120
+ }
1121
+
1122
  function buildPageSseFailureStatus(error) {
1123
  if (error && typeof error === 'object') {
1124
  if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AGENT_ENDPOINTS } from './agent-api-paths.mjs';
2
+
3
+ const DEFAULT_AGENT_DIAGNOSTICS_TIMEOUT_MS = 5000;
4
+
5
+ export async function enrichFailureWithAgentDiagnostics({
6
+ baseUrl,
7
+ authHeaders,
8
+ idempotencyKey,
9
+ failureOutput,
10
+ summary,
11
+ timeoutMs,
12
+ diagnosticsTimeoutMs,
13
+ fetchFn = fetch
14
+ }) {
15
+ const diagnosticsResult = await fetchAgentDiagnosticsByIdempotencyKey({
16
+ baseUrl,
17
+ authHeaders,
18
+ idempotencyKey,
19
+ timeoutMs,
20
+ diagnosticsTimeoutMs,
21
+ fetchFn
22
+ });
23
+
24
+ const diagnostics = diagnosticsResult.diagnostics;
25
+ const agentFailureDiagnostics = buildAgentFailureDiagnostics(diagnosticsResult, diagnostics);
26
+
27
+ return {
28
+ failureOutput: {
29
+ ...failureOutput,
30
+ ...(agentFailureDiagnostics ? { agent_failure_diagnostics: agentFailureDiagnostics } : {})
31
+ },
32
+ summary: mergeDiagnosticsIntoSummary(summary, diagnosticsResult, diagnostics)
33
+ };
34
+ }
35
+
36
+ async function fetchAgentDiagnosticsByIdempotencyKey({
37
+ baseUrl,
38
+ authHeaders,
39
+ idempotencyKey,
40
+ timeoutMs,
41
+ diagnosticsTimeoutMs,
42
+ fetchFn
43
+ }) {
44
+ const url = buildAgentEndpointUrl(baseUrl, AGENT_ENDPOINTS.agent_request_diagnostics_lookup);
45
+ url.searchParams.set('idempotency_key', idempotencyKey);
46
+ const controller = new AbortController();
47
+ const timeout = setTimeout(() => controller.abort(), resolveDiagnosticsTimeoutMs(timeoutMs, diagnosticsTimeoutMs));
48
+ try {
49
+ const response = await fetchFn(url, {
50
+ headers: authHeaders(),
51
+ signal: controller.signal
52
+ });
53
+ const text = await response.text();
54
+ if (!isJsonResponse(response) && text) {
55
+ return buildUnavailableDiagnosticsResult('non_json_response', response.status);
56
+ }
57
+ const result = parseJson(text);
58
+ if (response.status === 404 && result?.found === false) {
59
+ return { checked: true, found: false };
60
+ }
61
+ if (!response.ok) {
62
+ return buildUnavailableDiagnosticsResult(`status_${response.status}`, response.status);
63
+ }
64
+ if (!result || typeof result !== 'object' || typeof result.found !== 'boolean') {
65
+ return buildUnavailableDiagnosticsResult('invalid_response', response.status);
66
+ }
67
+ if (result.found === true && !isObject(result.diagnostics)) {
68
+ return buildUnavailableDiagnosticsResult('invalid_response', response.status);
69
+ }
70
+ return {
71
+ checked: true,
72
+ found: result.found,
73
+ diagnostics: result.found ? result.diagnostics : null
74
+ };
75
+ } catch (error) {
76
+ return {
77
+ checked: true,
78
+ found: false,
79
+ unavailable_reason: readFetchErrorKind(error)
80
+ };
81
+ } finally {
82
+ clearTimeout(timeout);
83
+ }
84
+ }
85
+
86
+ function buildAgentEndpointUrl(baseUrl, endpoint) {
87
+ return new URL(`${String(baseUrl).replace(/\/+$/, '')}${endpoint}`);
88
+ }
89
+
90
+ function buildUnavailableDiagnosticsResult(unavailableReason, status) {
91
+ return {
92
+ checked: true,
93
+ found: false,
94
+ unavailable_reason: unavailableReason,
95
+ ...(Number.isInteger(status) ? { http_status: status } : {})
96
+ };
97
+ }
98
+
99
+ function resolveDiagnosticsTimeoutMs(timeoutMs, diagnosticsTimeoutMs) {
100
+ return Math.min(
101
+ readPositiveInteger(timeoutMs) || DEFAULT_AGENT_DIAGNOSTICS_TIMEOUT_MS,
102
+ readPositiveInteger(diagnosticsTimeoutMs) || DEFAULT_AGENT_DIAGNOSTICS_TIMEOUT_MS
103
+ );
104
+ }
105
+
106
+ function isJsonResponse(response) {
107
+ const contentType = typeof response?.headers?.get === 'function' ? response.headers.get('content-type') : '';
108
+ return String(contentType || '').toLowerCase().includes('application/json');
109
+ }
110
+
111
+ function buildAgentFailureDiagnostics(result, diagnostics) {
112
+ if (!result?.checked) return undefined;
113
+ if (!result.found) {
114
+ return {
115
+ checked: true,
116
+ found: false,
117
+ unavailable_reason: result.unavailable_reason || null,
118
+ http_status: result.http_status ?? null
119
+ };
120
+ }
121
+ const request = isObject(diagnostics?.request) ? diagnostics.request : undefined;
122
+ const error = isObject(diagnostics?.error) ? diagnostics.error : undefined;
123
+ const errorDiagnostics = isObject(error?.diagnostics) ? error.diagnostics : undefined;
124
+ const response = isObject(diagnostics?.response) ? diagnostics.response : undefined;
125
+ const execution = isObject(response?.execution) ? response.execution : undefined;
126
+ return {
127
+ checked: true,
128
+ found: true,
129
+ request_id: readString(request?.request_id),
130
+ status: readString(request?.status),
131
+ error_code: readString(error?.code),
132
+ retryable: typeof error?.retryable === 'boolean' ? error.retryable : undefined,
133
+ selected_channel_id: readString(errorDiagnostics?.selected_channel_id) || readString(execution?.selected_channel_id),
134
+ upstream_host: readString(errorDiagnostics?.upstream_host) || readString(execution?.upstream_host),
135
+ transport_error_kind: readString(errorDiagnostics?.transport_error_kind)
136
+ };
137
+ }
138
+
139
+ function mergeDiagnosticsIntoSummary(summary, diagnosticsResult, diagnostics) {
140
+ if (!diagnosticsResult?.checked) return summary;
141
+ const request = isObject(diagnostics?.request) ? diagnostics.request : undefined;
142
+ const error = isObject(diagnostics?.error) ? diagnostics.error : undefined;
143
+ const errorDiagnostics = isObject(error?.diagnostics) ? error.diagnostics : undefined;
144
+ const response = isObject(diagnostics?.response) ? diagnostics.response : undefined;
145
+ const execution = isObject(response?.execution) ? response.execution : undefined;
146
+ const diagnosticsRetryable = typeof error?.retryable === 'boolean' ? error.retryable : undefined;
147
+ return {
148
+ ...summary,
149
+ request_id: preferExistingString(summary.request_id, request?.request_id) || null,
150
+ selected_channel_id:
151
+ preferExistingString(summary.selected_channel_id, errorDiagnostics?.selected_channel_id, execution?.selected_channel_id) ||
152
+ null,
153
+ upstream_host: preferExistingString(summary.upstream_host, errorDiagnostics?.upstream_host, execution?.upstream_host) || null,
154
+ transport_error_kind: preferExistingString(summary.transport_error_kind, errorDiagnostics?.transport_error_kind) || null,
155
+ retryable: diagnosticsRetryable ?? summary.retryable,
156
+ agent_diagnostics_checked: true,
157
+ agent_diagnostics_found: diagnosticsResult.found === true,
158
+ agent_diagnostics_unavailable_reason: diagnosticsResult.found ? null : diagnosticsResult.unavailable_reason || null,
159
+ agent_diagnostics_http_status: diagnosticsResult.found ? null : diagnosticsResult.http_status ?? null,
160
+ next_action: readDiagnosticsNextAction(summary.next_action, diagnosticsRetryable)
161
+ };
162
+ }
163
+
164
+ function readDiagnosticsNextAction(currentNextAction, diagnosticsRetryable) {
165
+ if (diagnosticsRetryable === true) return 'retry_after_wait';
166
+ if (diagnosticsRetryable === false) return 'diagnose_then_new_idempotency_key';
167
+ return currentNextAction || 'diagnose_then_new_idempotency_key';
168
+ }
169
+
170
+ function preferExistingString(...values) {
171
+ for (const value of values) {
172
+ const stringValue = readString(value);
173
+ if (stringValue) return stringValue;
174
+ }
175
+ return undefined;
176
+ }
177
+
178
+ function parseJson(text) {
179
+ try {
180
+ return text ? JSON.parse(text) : null;
181
+ } catch {
182
+ return null;
183
+ }
184
+ }
185
+
186
+ function readFetchErrorKind(error) {
187
+ const name = typeof error?.name === 'string' ? error.name : '';
188
+ if (name === 'AbortError') return 'diagnostics_timeout';
189
+ const causeCode = typeof error?.cause?.code === 'string' ? error.cause.code : '';
190
+ if (causeCode) return causeCode;
191
+ return 'diagnostics_fetch_failed';
192
+ }
193
+
194
+ function isObject(value) {
195
+ return Boolean(value && typeof value === 'object' && !Array.isArray(value));
196
+ }
197
+
198
+ function readString(value) {
199
+ return typeof value === 'string' && value ? value : undefined;
200
+ }
201
+
202
+ function readPositiveInteger(value) {
203
+ return Number.isSafeInteger(value) && value > 0 ? value : undefined;
204
+ }
skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs CHANGED
@@ -17,6 +17,7 @@ export function buildSuccessSummary({ result, routing, timing, idempotencyKey, b
17
  const serverTiming = readObject(result?.timing);
18
  const execution = readObject(result?.execution);
19
  const images = Array.isArray(result?.images) ? result.images : [];
 
20
  const timingSummary = buildTimingSummary({ clientTiming: timing, serverTiming });
21
  return stableSummary({
22
  ok: true,
@@ -26,6 +27,8 @@ export function buildSuccessSummary({ result, routing, timing, idempotencyKey, b
26
  artifact_ids: images.map((image) => image?.id).filter((value) => typeof value === 'string' && value),
27
  content_urls: readImageUrls(images, ['content_url', 'path']),
28
  absolute_content_urls: readImageUrls(images, ['absolute_content_url', 'absolute_path']),
 
 
29
  cached: typeof result?.cached === 'boolean' ? result.cached : undefined,
30
  started_at: timingSummary.started_at,
31
  completed_at: timingSummary.completed_at,
@@ -51,15 +54,22 @@ export function buildFailureSummary({ errorBody, routing, timing, idempotencyKey
51
  const error = readObject(errorBody?.error) || readObject(errorBody);
52
  const errorMessage = readString(error?.message) || readString(errorBody?.error);
53
  const diagnostics = readObject(error?.diagnostics);
 
 
 
54
  const timingSummary = buildTimingSummary({ clientTiming: timing, diagnostics });
55
  return stableSummary({
56
  ok: false,
57
  billable,
58
  request_id: readString(error?.request_id) || null,
59
  idempotency_key: idempotencyKey,
60
- artifact_ids: [],
61
- content_urls: [],
62
- absolute_content_urls: [],
 
 
 
 
63
  started_at: timingSummary.started_at,
64
  completed_at: timingSummary.completed_at,
65
  elapsed_ms: timingSummary.elapsed_ms,
@@ -104,6 +114,10 @@ function stableSummary(value) {
104
  artifact_ids: Array.isArray(value.artifact_ids) ? value.artifact_ids : [],
105
  content_urls: Array.isArray(value.content_urls) ? value.content_urls : [],
106
  absolute_content_urls: Array.isArray(value.absolute_content_urls) ? value.absolute_content_urls : [],
 
 
 
 
107
  route_mode: value.route_mode ?? null,
108
  image_backend: value.image_backend ?? null,
109
  stream_mode: value.stream_mode ?? null,
@@ -114,7 +128,11 @@ function stableSummary(value) {
114
  retry_after_ms: value.retry_after_ms ?? null,
115
  retry_after_seconds: value.retry_after_seconds ?? null,
116
  cooldown_until: value.cooldown_until ?? null,
117
- cooldown_target: value.cooldown_target ?? null
 
 
 
 
118
  };
119
  }
120
 
@@ -154,6 +172,32 @@ function readImageUrls(images, fields) {
154
  .filter((value) => typeof value === 'string' && value);
155
  }
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  function buildTimingSummary({ clientTiming, serverTiming, diagnostics }) {
158
  const serverTimingElapsedMs = readNonNegativeNumber(serverTiming?.elapsed_ms);
159
  const diagnosticsElapsedMs = readNonNegativeNumber(diagnostics?.elapsed_ms);
 
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,
 
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,
 
54
  const error = readObject(errorBody?.error) || readObject(errorBody);
55
  const errorMessage = readString(error?.message) || readString(errorBody?.error);
56
  const diagnostics = readObject(error?.diagnostics);
57
+ const response = readObject(errorBody?.response);
58
+ const images = Array.isArray(response?.images) ? response.images : [];
59
+ const imageDimensions = readResponseImageDimensions(images);
60
  const timingSummary = buildTimingSummary({ clientTiming: timing, diagnostics });
61
  return stableSummary({
62
  ok: false,
63
  billable,
64
  request_id: readString(error?.request_id) || null,
65
  idempotency_key: idempotencyKey,
66
+ artifact_ids: images.map((image) => image?.id).filter((value) => typeof value === 'string' && value),
67
+ content_urls: readImageUrls(images, ['content_url', 'path']),
68
+ absolute_content_urls: readImageUrls(images, ['absolute_content_url', 'absolute_path']),
69
+ image_dimensions: imageDimensions,
70
+ expected_dimensions: readDimensionObject(error?.expected_dimensions),
71
+ actual_dimensions: readDimensionObject(error?.actual_dimensions),
72
+ dimension_check_failed: error?.code === 'dimension_check_failed' ? true : undefined,
73
  started_at: timingSummary.started_at,
74
  completed_at: timingSummary.completed_at,
75
  elapsed_ms: timingSummary.elapsed_ms,
 
114
  artifact_ids: Array.isArray(value.artifact_ids) ? value.artifact_ids : [],
115
  content_urls: Array.isArray(value.content_urls) ? value.content_urls : [],
116
  absolute_content_urls: Array.isArray(value.absolute_content_urls) ? value.absolute_content_urls : [],
117
+ image_dimensions: Array.isArray(value.image_dimensions) ? value.image_dimensions : [],
118
+ expected_dimensions: value.expected_dimensions ?? null,
119
+ actual_dimensions: value.actual_dimensions ?? null,
120
+ dimension_check_failed: value.dimension_check_failed ?? false,
121
  route_mode: value.route_mode ?? null,
122
  image_backend: value.image_backend ?? null,
123
  stream_mode: value.stream_mode ?? null,
 
128
  retry_after_ms: value.retry_after_ms ?? null,
129
  retry_after_seconds: value.retry_after_seconds ?? null,
130
  cooldown_until: value.cooldown_until ?? null,
131
+ cooldown_target: value.cooldown_target ?? null,
132
+ agent_diagnostics_checked: value.agent_diagnostics_checked ?? false,
133
+ agent_diagnostics_found: value.agent_diagnostics_found ?? false,
134
+ agent_diagnostics_unavailable_reason: value.agent_diagnostics_unavailable_reason ?? null,
135
+ agent_diagnostics_http_status: value.agent_diagnostics_http_status ?? null
136
  };
137
  }
138
 
 
172
  .filter((value) => typeof value === 'string' && value);
173
  }
174
 
175
+ function readResponseImageDimensions(images) {
176
+ return images
177
+ .map((image) => readDimensionObject(image?.dimensions) || readDimensionObject(image?.metadata?.dimensions) || readImageTopLevelDimensions(image))
178
+ .filter((value) => value !== undefined);
179
+ }
180
+
181
+ function readImageTopLevelDimensions(image) {
182
+ if (!readObject(image)) return undefined;
183
+ if (!Object.prototype.hasOwnProperty.call(image, 'width')) return undefined;
184
+ if (!Object.prototype.hasOwnProperty.call(image, 'height')) return undefined;
185
+ return readDimensionObject(image);
186
+ }
187
+
188
+ function readDimensionObject(value) {
189
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined;
190
+ const width = readPositiveInteger(value.width);
191
+ const height = readPositiveInteger(value.height);
192
+ if (width === undefined || height === undefined) return undefined;
193
+ return { width, height };
194
+ }
195
+
196
+ function readPositiveInteger(value) {
197
+ if (!Number.isInteger(value) || value <= 0) return undefined;
198
+ return value;
199
+ }
200
+
201
  function buildTimingSummary({ clientTiming, serverTiming, diagnostics }) {
202
  const serverTimingElapsedMs = readNonNegativeNumber(serverTiming?.elapsed_ms);
203
  const diagnosticsElapsedMs = readNonNegativeNumber(diagnostics?.elapsed_ms);
src/app/api/agent/images/edit/route.ts CHANGED
@@ -36,7 +36,7 @@ export async function POST(request: NextRequest) {
36
  requestHash
37
  );
38
  if (existingResult) {
39
- return agentBeginResultResponse(existingResult, store);
40
  }
41
  const preparation = await prepareAgentEdit(formData, request.headers);
42
  const beginResult = await store.beginRequest({
 
36
  requestHash
37
  );
38
  if (existingResult) {
39
+ return await agentBeginResultResponse(existingResult, store);
40
  }
41
  const preparation = await prepareAgentEdit(formData, request.headers);
42
  const beginResult = await store.beginRequest({
src/app/api/agent/images/generate/route.ts CHANGED
@@ -34,7 +34,7 @@ export async function POST(request: NextRequest) {
34
  requestHash
35
  );
36
  if (existingResult) {
37
- return agentBeginResultResponse(existingResult, store);
38
  }
39
  const preparation = prepareAgentGenerate(imageRequest, request.headers);
40
  const beginResult = await store.beginRequest({
 
34
  requestHash
35
  );
36
  if (existingResult) {
37
+ return await agentBeginResultResponse(existingResult, store);
38
  }
39
  const preparation = prepareAgentGenerate(imageRequest, request.headers);
40
  const beginResult = await store.beginRequest({