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