Spaces:
Running
Running
Deploy 4566102 to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@45661020d068d46d33ab0321805725abdd88da69
This view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +21 -1
- .env.real-smoke.example +45 -0
- .gitignore +1 -0
- README.md +71 -27
- docker-compose.memory.yml +4 -0
- docs/deployment/huggingface-space-free.md +53 -108
- docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +34 -0
- docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md +189 -0
- package.json +10 -2
- scripts/agent-doctor.mjs +57 -0
- scripts/agent-skill-scripts.test.mjs +1070 -3
- scripts/command-center-utils.mjs +132 -0
- scripts/command-center.test.mjs +429 -0
- scripts/deploy-hf-space.mjs +216 -0
- scripts/deploy-hf-space.test.mjs +91 -0
- scripts/deploy-local.mjs +112 -0
- scripts/doctor-hf-space.mjs +49 -123
- scripts/doctor.mjs +55 -0
- scripts/env-utils.mjs +15 -0
- scripts/env-utils.test.mjs +52 -0
- scripts/hf-space-doctor-utils.mjs +45 -32
- scripts/hf-space-doctor-utils.test.mjs +31 -4
- scripts/keepalive-hf-space.mjs +2 -16
- scripts/local-image-upstream-fixture.mjs +227 -0
- scripts/local-image-upstream-fixture.test.mjs +199 -0
- scripts/smoke-hf-space-memory.mjs +4 -1
- scripts/smoke-hf-space-memory.test.mjs +23 -0
- scripts/smoke-image-upstream-compat.mjs +310 -0
- scripts/smoke-image-upstream-local-final-gate.mjs +244 -0
- scripts/smoke-image-upstream-local-final-gate.test.mjs +49 -0
- scripts/smoke-image-upstream-real.mjs +791 -0
- scripts/smoke-image-upstream-real.test.mjs +1070 -0
- scripts/status.mjs +263 -0
- scripts/test-postgres-live.mjs +28 -3
- scripts/test-postgres-live.test.mjs +143 -0
- scripts/verify.mjs +86 -0
- skills/gpt-image-playground-agent/SKILL.md +50 -14
- skills/gpt-image-playground-agent/references/api.md +46 -8
- skills/gpt-image-playground-agent/scripts/edit-image.mjs +43 -0
- skills/gpt-image-playground-agent/scripts/generate-image.mjs +572 -50
- skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +8 -9
- src/app/api/agent/agent-routes.test.ts +1160 -94
- src/app/api/agent/capabilities/route.ts +2 -0
- src/app/api/agent/images/edit/route.ts +16 -5
- src/app/api/agent/images/generate/route.ts +14 -5
- src/app/api/agent/jobs/[id]/result/route.ts +2 -2
- src/app/api/agent/jobs/images/generate/route.ts +1 -0
- src/app/api/auth-status/route.test.ts +24 -0
- src/app/api/auth-status/route.ts +1 -1
- src/app/api/auth-verify/route.test.ts +15 -0
.env.example
CHANGED
|
@@ -43,12 +43,32 @@ OPENAI_API_BASE_URL=
|
|
| 43 |
# OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
|
| 44 |
# OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=60000
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
# 可选:实验 Responses API 图片后端。默认关闭。
|
| 47 |
-
# 开启后仍需请求显式传入
|
|
|
|
| 48 |
# 表单 model 只作为 image_generation 工具模型,不会复用为 /responses 顶层模型。
|
| 49 |
# ENABLE_RESPONSES_IMAGE_BACKEND=true
|
| 50 |
# OPENAI_RESPONSES_API_MODEL=
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
| 53 |
APP_PASSWORD=
|
| 54 |
|
|
|
|
| 43 |
# OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
|
| 44 |
# OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=60000
|
| 45 |
|
| 46 |
+
# 可选:多上游图片兼容层默认策略。默认保持 Images API JSON 基线。
|
| 47 |
+
# IMAGE_GENERATION_BACKEND=images-api
|
| 48 |
+
# IMAGE_STREAMING_STRATEGY=auto
|
| 49 |
+
|
| 50 |
# 可选:实验 Responses API 图片后端。默认关闭。
|
| 51 |
+
# 开启后仍需请求显式传入 image_backend=responses-image-generation
|
| 52 |
+
# 或兼容别名 imageBackend=responses,并单独配置 /responses 顶层模型。
|
| 53 |
# 表单 model 只作为 image_generation 工具模型,不会复用为 /responses 顶层模型。
|
| 54 |
# ENABLE_RESPONSES_IMAGE_BACKEND=true
|
| 55 |
# OPENAI_RESPONSES_API_MODEL=
|
| 56 |
|
| 57 |
+
# 可选:独立真实上游 smoke 目标。默认不触发计费请求,必须显式运行 --allow-billable。
|
| 58 |
+
# 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。
|
| 59 |
+
# IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://original-new-api.example.com/v1
|
| 60 |
+
# IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=
|
| 61 |
+
# IMAGE_REAL_SMOKE_GAOREN_BASE_URL=https://gaoren-new-api.example.com/v1
|
| 62 |
+
# IMAGE_REAL_SMOKE_GAOREN_API_KEY=
|
| 63 |
+
# IMAGE_REAL_SMOKE_SUB2API_BASE_URL=https://sub2api.example.com/v1
|
| 64 |
+
# IMAGE_REAL_SMOKE_SUB2API_API_KEY=
|
| 65 |
+
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=https://sub2api.example.com/v1
|
| 66 |
+
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
|
| 67 |
+
# IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.example.com/v1
|
| 68 |
+
# IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
|
| 69 |
+
# IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
|
| 70 |
+
# IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
|
| 71 |
+
|
| 72 |
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
| 73 |
APP_PASSWORD=
|
| 74 |
|
.env.real-smoke.example
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Independent real upstream smoke targets.
|
| 2 |
+
# Copy this file to .env.real-smoke.local and fill only the targets you can run.
|
| 3 |
+
# Do not commit .env.real-smoke.local.
|
| 4 |
+
# Final gate:
|
| 5 |
+
# npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable
|
| 6 |
+
|
| 7 |
+
# Original QuantumNous/new-api compatible Images API JSON.
|
| 8 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=
|
| 9 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=
|
| 10 |
+
# IMAGE_REAL_SMOKE_ORIGINAL_MODEL=gpt-image-2
|
| 11 |
+
# IMAGE_REAL_SMOKE_ORIGINAL_SIZE=1024x1024
|
| 12 |
+
# IMAGE_REAL_SMOKE_ORIGINAL_QUALITY=low
|
| 13 |
+
|
| 14 |
+
# gaoren002/new-api Images API SSE and keepalive branch.
|
| 15 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL=
|
| 16 |
+
IMAGE_REAL_SMOKE_GAOREN_API_KEY=
|
| 17 |
+
# IMAGE_REAL_SMOKE_GAOREN_MODEL=gpt-image-2
|
| 18 |
+
# IMAGE_REAL_SMOKE_GAOREN_SIZE=1024x1024
|
| 19 |
+
# IMAGE_REAL_SMOKE_GAOREN_QUALITY=low
|
| 20 |
+
|
| 21 |
+
# Wei-Shaw/sub2api Images API SSE.
|
| 22 |
+
IMAGE_REAL_SMOKE_SUB2API_BASE_URL=
|
| 23 |
+
IMAGE_REAL_SMOKE_SUB2API_API_KEY=
|
| 24 |
+
# IMAGE_REAL_SMOKE_SUB2API_MODEL=gpt-image-2
|
| 25 |
+
# IMAGE_REAL_SMOKE_SUB2API_SIZE=1024x1024
|
| 26 |
+
# IMAGE_REAL_SMOKE_SUB2API_QUALITY=low
|
| 27 |
+
|
| 28 |
+
# sub2api Responses image_generation bridge. If omitted, the smoke script can reuse IMAGE_REAL_SMOKE_SUB2API_*.
|
| 29 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=
|
| 30 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY=
|
| 31 |
+
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2
|
| 32 |
+
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL=gpt-5.4
|
| 33 |
+
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_SIZE=1024x1024
|
| 34 |
+
# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low
|
| 35 |
+
|
| 36 |
+
# GPT2Image style Responses image_generation SSE.
|
| 37 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=
|
| 38 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=
|
| 39 |
+
# IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2
|
| 40 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4
|
| 41 |
+
# IMAGE_REAL_SMOKE_GPT2IMAGE_SIZE=1024x1024
|
| 42 |
+
# IMAGE_REAL_SMOKE_GPT2IMAGE_QUALITY=low
|
| 43 |
+
|
| 44 |
+
# Shared timeout for each real upstream smoke case.
|
| 45 |
+
IMAGE_REAL_SMOKE_TIMEOUT_MS=240000
|
.gitignore
CHANGED
|
@@ -36,6 +36,7 @@ dev-server*.log
|
|
| 36 |
.env*
|
| 37 |
!.env.example
|
| 38 |
!.env.agent.example
|
|
|
|
| 39 |
|
| 40 |
# vercel
|
| 41 |
.vercel
|
|
|
|
| 36 |
.env*
|
| 37 |
!.env.example
|
| 38 |
!.env.agent.example
|
| 39 |
+
!.env.real-smoke.example
|
| 40 |
|
| 41 |
# vercel
|
| 42 |
.vercel
|
README.md
CHANGED
|
@@ -127,7 +127,7 @@ http://localhost:4783
|
|
| 127 |
- 内置遮罩工具:直接在图片上绘制遮罩,也可以上传 PNG 遮罩。
|
| 128 |
- 完整参数控制:模型、尺寸、质量、输出格式、压缩、背景、审核级别、生成数量。
|
| 129 |
- 4K 与自定义尺寸:支持 2K/4K 预设和手动输入宽高,并在前端校验尺寸约束。
|
| 130 |
-
- 流式输出:
|
| 131 |
- 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
|
| 132 |
- 发送到编辑:从生成结果或历史记录直接进入编辑模式。
|
| 133 |
- 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
|
|
@@ -139,17 +139,20 @@ http://localhost:4783
|
|
| 139 |
## 默认行为
|
| 140 |
|
| 141 |
- 图片生成默认使用 `quality=high`。如需降低成本或让上游自行选择质量,可在页面或 Agent 请求中显式改为 `auto`、`medium` 或 `low`。
|
| 142 |
-
- 页面默认开启流式预览
|
| 143 |
-
- 服务端会把官方 OpenAI Images 流式事件
|
| 144 |
- 流式请求失败时会显示原始错误状态和排查建议,不会自动改用非流式请求,以避免隐藏网关、限流或上游故障。
|
| 145 |
|
| 146 |
## 图片后端路径
|
| 147 |
|
| 148 |
-
- 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。
|
| 149 |
-
-
|
| 150 |
-
-
|
|
|
|
|
|
|
|
|
|
| 151 |
- Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;页面表单里的图片模型只传给 `image_generation` 工具。
|
| 152 |
-
- Responses API 实验路径
|
| 153 |
|
| 154 |
## 编辑与遮罩
|
| 155 |
|
|
@@ -199,7 +202,7 @@ Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统
|
|
| 199 |
| `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 |
|
| 200 |
| `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 |
|
| 201 |
| `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 |
|
| 202 |
-
| `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合
|
| 203 |
| `GET /api/agent/jobs/{id}` | 轮询 job 状态。 |
|
| 204 |
| `GET /api/agent/jobs/{id}/result` | 读取完成后的标准图片响应,运行中返回可重试错误。 |
|
| 205 |
| `GET /api/agent/artifacts/{id}` | 查询产物元数据。 |
|
|
@@ -300,8 +303,10 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case
|
|
| 300 |
| `OPENAI_CHANNEL_N_API_KEYS` | 否 | 无 | 第 N 个渠道的一个或多个 API Key,多个 key 用英文逗号分隔。 |
|
| 301 |
| `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 否 | 继承全局值 | 第 N 个渠道的失败冷却时间。 |
|
| 302 |
| `ENABLE_STREAMING_BATCH` | 否 | `false` | 显式设为 `true` 后,流式模式下 `n>1` 会拆成多个 `n=1` 任务并发执行。 |
|
| 303 |
-
| `
|
| 304 |
-
| `
|
|
|
|
|
|
|
| 305 |
| `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 |
|
| 306 |
| `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 |
|
| 307 |
| `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 |
|
|
@@ -465,33 +470,44 @@ npm install
|
|
| 465 |
|
| 466 |
要求 Node.js 20 或更高版本。Hugging Face CLI 安装方式以官方文档为准;当前官方入口是 `hf` 命令,登录使用 Hugging Face Access Token。
|
| 467 |
|
| 468 |
-
|
|
|
|
|
|
|
| 469 |
|
| 470 |
```bash
|
| 471 |
-
npm run
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
| 474 |
```
|
| 475 |
|
| 476 |
-
|
| 477 |
-
`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。
|
| 478 |
|
| 479 |
-
|
| 480 |
|
| 481 |
```bash
|
| 482 |
-
|
| 483 |
-
hf auth login
|
| 484 |
```
|
| 485 |
|
| 486 |
-
|
|
|
|
|
|
|
| 487 |
|
| 488 |
```bash
|
| 489 |
-
npm run
|
| 490 |
```
|
| 491 |
|
| 492 |
-
该
|
|
|
|
|
|
|
| 493 |
|
| 494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
|
| 496 |
`memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。
|
| 497 |
|
|
@@ -542,15 +558,43 @@ docker logs -f gpt-image-playground-customer
|
|
| 542 |
| `npm run dev` | 启动本地开发服务。 |
|
| 543 |
| `npm run build` | 执行生产构建。 |
|
| 544 |
| `npm run start` | 启动生产模式服务。 |
|
| 545 |
-
| `npm run
|
| 546 |
-
| `npm run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
| `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
|
| 548 |
-
| `npm run
|
| 549 |
-
| `npm run smoke:
|
|
|
|
|
|
|
| 550 |
| `npm run lint` | 检查 `src/` 代码。 |
|
| 551 |
| `npm run lint:scripts` | 跨平台检查仓库脚本和 skill 脚本语法。 |
|
| 552 |
| `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 |
|
| 553 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 554 |
## 常见问题
|
| 555 |
|
| 556 |
### 提示未检测到 Node.js
|
|
|
|
| 127 |
- 内置遮罩工具:直接在图片上绘制遮罩,也可以上传 PNG 遮罩。
|
| 128 |
- 完整参数控制:模型、尺寸、质量、输出格式、压缩、背景、审核级别、生成数量。
|
| 129 |
- 4K 与自定义尺寸:支持 2K/4K 预设和手动输入宽高,并在前端校验尺寸约束。
|
| 130 |
+
- 流式输出:用户显式开启后支持生成和编辑过程中的局部图片预览。
|
| 131 |
- 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
|
| 132 |
- 发送到编辑:从生成结果或历史记录直接进入编辑模式。
|
| 133 |
- 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
|
|
|
|
| 139 |
## 默认行为
|
| 140 |
|
| 141 |
- 图片生成默认使用 `quality=high`。如需降低成本或让上游自行选择质量,可在页面或 Agent 请求中显式改为 `auto`、`medium` 或 `low`。
|
| 142 |
+
- 页面默认不发送流式请求;用户显式开启流式预览后,才会走 SSE 路径。并发流式批处理仍默认关闭,只有设置 `ENABLE_STREAMING_BATCH=true` 后才会把 `n>1` 拆成多个流式任务。
|
| 143 |
+
- 服务端会把官方 OpenAI Images 流式事件、gaoren002/new-api 与 sub2api 图片 SSE、OtokAPI `image.generation.*`、Responses `image_generation_call` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。
|
| 144 |
- 流式请求失败时会显示原始错误状态和排查建议,不会自动改用非流式请求,以避免隐藏网关、限流或上游故障。
|
| 145 |
|
| 146 |
## 图片后端路径
|
| 147 |
|
| 148 |
+
- 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。原版 new-api 和 sub2api 普通 JSON 能力保持这个基线。
|
| 149 |
+
- 流式能力由请求字段或环境变量显式控制:`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。`auto` 不会凭仓库名假设上游能力;Agent 辅助脚本对 `max_edge>2048` 的单次文生图默认优先使用页面端 `/api/images` SSE,失败后先诊断,再显式选择 Agent JSON 或 job 路径。
|
| 150 |
+
- 流式请求在没有 partial image 前只显示连接保持状态,不会把 keepalive 当成图片预览或成功结果。
|
| 151 |
+
- gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容仅发生在事件适配层:partial image 只作为预览,只有最终 completed base64 才会保存为 artifact;缺最终 base64 或仅返回远程 URL 会显式失败。
|
| 152 |
+
- Responses API image generation 是实验路径,默认关闭。只有同时设置 `ENABLE_RESPONSES_IMAGE_BACKEND=true`、配置 `OPENAI_RESPONSES_API_MODEL`,并在请求中显式传入 `image_backend=responses-image-generation` 或兼容别名 `imageBackend=responses` 时,服务端才会调用 `/responses` 并读取 `image_generation_call.result`。
|
| 153 |
+
- Agent capabilities 会同时暴露 `supported.image_backends` 枚举和 `supported.enabled_image_backends` 当前启用后端;自动化脚本应以后者和 `image_backend_requirements` 判断 runtime 是否已准备好。
|
| 154 |
- Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;页面表单里的图片模型只传给 `image_generation` 工具。
|
| 155 |
+
- Responses API 实验路径支持单张 `generate` 的非流式和上游 SSE 消费,不替换默认 Images API,不接入编辑表单。Agent generate 对外仍返回最终 JSON,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用服务端内部上游 SSE 消费。
|
| 156 |
|
| 157 |
## 编辑与遮罩
|
| 158 |
|
|
|
|
| 202 |
| `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 |
|
| 203 |
| `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 |
|
| 204 |
| `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 |
|
| 205 |
+
| `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合显式选择 job polling 的长耗时请求;大图单次文生图默认优先按 capabilities 使用页面端 `/api/images` SSE。 |
|
| 206 |
| `GET /api/agent/jobs/{id}` | 轮询 job 状态。 |
|
| 207 |
| `GET /api/agent/jobs/{id}/result` | 读取完成后的标准图片响应,运行中返回可重试错误。 |
|
| 208 |
| `GET /api/agent/artifacts/{id}` | 查询产物元数据。 |
|
|
|
|
| 303 |
| `OPENAI_CHANNEL_N_API_KEYS` | 否 | 无 | 第 N 个渠道的一个或多个 API Key,多个 key 用英文逗号分隔。 |
|
| 304 |
| `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 否 | 继承全局值 | 第 N 个渠道的失败冷却时间。 |
|
| 305 |
| `ENABLE_STREAMING_BATCH` | 否 | `false` | 显式设为 `true` 后,流式模式下 `n>1` 会拆成多个 `n=1` 任务并发执行。 |
|
| 306 |
+
| `IMAGE_GENERATION_BACKEND` | 否 | `images-api` | 服务端默认图片后端,可选 `images-api` 或 `responses-image-generation`。请求字段可覆盖。 |
|
| 307 |
+
| `IMAGE_STREAMING_STRATEGY` | 否 | `auto` | 服务端默认流式兼容策略,可选 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。请求字段可覆盖。 |
|
| 308 |
+
| `ENABLE_RESPONSES_IMAGE_BACKEND` | 否 | `false` | 实验开关。显式设为 `true` 后,`image_backend=responses-image-generation` 或兼容别名 `imageBackend=responses` 请求才可调用 Responses API image generation。 |
|
| 309 |
+
| `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 Responses 图片后端时必须设置,或在请求中传 `responsesModel`。 |
|
| 310 |
| `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 |
|
| 311 |
| `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 |
|
| 312 |
| `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 |
|
|
|
|
| 470 |
|
| 471 |
要求 Node.js 20 或更高版本。Hugging Face CLI 安装方式以官方文档为准;当前官方入口是 `hf` 命令,登录使用 Hugging Face Access Token。
|
| 472 |
|
| 473 |
+
本仓库只保留一种推荐的管理员交互方式:先用顶层命令判断状态,再进入具体部署命令。Hugging Face Space 操作使用官方 `hf` CLI;不要再维护本机 access 文件,也不要把 Space Secret 写入仓库。
|
| 474 |
+
|
| 475 |
+
常用入口:
|
| 476 |
|
| 477 |
```bash
|
| 478 |
+
npm run status
|
| 479 |
+
npm run doctor
|
| 480 |
+
npm run verify
|
| 481 |
+
npm run deploy:local
|
| 482 |
+
npm run deploy:space
|
| 483 |
+
npm run agent:doctor
|
| 484 |
```
|
| 485 |
|
| 486 |
+
`status` 只读输出 git、Node、固定 Space 目标、Agent capabilities 路径、仓库 Skill 入口和独立真实图片上游 smoke 配置摘要。它会按 shell 环境变量、`.env.real-smoke.local`、`.env.local` 的优先级判断真实 smoke 配置是否齐全,但不会输出 URL 或 API Key;`doctor` 汇总本机与 HF Space 诊断;`verify` 执行提交前基线,需要真实 PostgreSQL gate 时加 `--postgres`;`deploy:local` 重建本地 Docker 并探测真实端点;`deploy:space` 是 HF Space 发布的稳定别名;`agent:doctor` 对当前 Agent API 做只读契约检查。
|
|
|
|
| 487 |
|
| 488 |
+
如果只想诊断 HF Space 前置条件,可运行:
|
| 489 |
|
| 490 |
```bash
|
| 491 |
+
npm run doctor:hf-space
|
|
|
|
| 492 |
```
|
| 493 |
|
| 494 |
+
该命令会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、���端 Variables 和远端 Secrets;不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
|
| 495 |
+
|
| 496 |
+
部署当前干净的 git HEAD 到固定 Space:
|
| 497 |
|
| 498 |
```bash
|
| 499 |
+
npm run deploy:space
|
| 500 |
```
|
| 501 |
|
| 502 |
+
该脚本会使用 `git archive HEAD` 生成临时源码目录,通过 `hf upload` 上传到 `misonL/gpt-image-playground-customer`,等待新 Space commit 进入 `RUNNING`,并执行只读公网端点检查。若工作区有未提交改动,脚本会直接失败,避免把本地临时状态误当成可复现发布。
|
| 503 |
+
|
| 504 |
+
配置或轮换 Space Secret 时,直接使用官方 `hf` CLI:
|
| 505 |
|
| 506 |
+
```bash
|
| 507 |
+
hf spaces variables add misonL/gpt-image-playground-customer -e AGENT_STATE_BACKEND=memory
|
| 508 |
+
hf spaces secrets add misonL/gpt-image-playground-customer -s APP_PASSWORD=<page-access-code>
|
| 509 |
+
hf spaces secrets add misonL/gpt-image-playground-customer -s AGENT_API_TOKEN=<long-random-agent-token>
|
| 510 |
+
```
|
| 511 |
|
| 512 |
`memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。
|
| 513 |
|
|
|
|
| 558 |
| `npm run dev` | 启动本地开发服务。 |
|
| 559 |
| `npm run build` | 执行生产构建。 |
|
| 560 |
| `npm run start` | 启动生产模式服务。 |
|
| 561 |
+
| `npm run status` | 只读输出 git、Node、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。 |
|
| 562 |
+
| `npm run doctor` | 运行统一诊断入口,默认包含 HF Space 只读远端检查。 |
|
| 563 |
+
| `npm run verify` | 执行提交前基线:测试、lint、脚本语法、构建和 `git diff --check`;加 `-- --postgres` 会包含 live PostgreSQL gate。 |
|
| 564 |
+
| `npm run deploy:local` | 重建本地 Docker 服务并探测 `/api/auth-status`、`/api/runtime-capabilities`、`/api/agent/capabilities`;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 |
|
| 565 |
+
| `npm run deploy:space` | 上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。 |
|
| 566 |
+
| `npm run agent:doctor` | 通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。 |
|
| 567 |
+
| `npm run deploy:hf-space` | 使用官方 `hf` CLI 上传当前干净 git HEAD 到固定 Space 并做只读公网验证。 |
|
| 568 |
+
| `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、固定 Space 目标和远端配置。 |
|
| 569 |
| `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
|
| 570 |
+
| `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约;慢机器可设置 `HF_SPACE_SMOKE_READY_TIMEOUT_MS`。 |
|
| 571 |
+
| `npm run smoke:image-upstream-compat` | 启动本地 mock 上游,验证 Images API、new-api/sub2api SSE 和 Responses image_generation 兼容契约。 |
|
| 572 |
+
| `npm run smoke:image-upstream-local` | 启动本地 fixture,并通过真实 smoke final gate 跑满 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse` 五个独立场景。 |
|
| 573 |
+
| `npm run smoke:image-upstream-real` | 检查真实上游 smoke 配置;加 `-- --allow-billable` 后才会触发真实生图。 |
|
| 574 |
| `npm run lint` | 检查 `src/` 代码。 |
|
| 575 |
| `npm run lint:scripts` | 跨平台检查仓库脚本和 skill 脚本语法。 |
|
| 576 |
| `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 |
|
| 577 |
|
| 578 |
+
真实上游 smoke 使用以下环境变量前缀逐类配置:`IMAGE_REAL_SMOKE_ORIGINAL_*`、`IMAGE_REAL_SMOKE_GAOREN_*`、`IMAGE_REAL_SMOKE_SUB2API_*`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`、`IMAGE_REAL_SMOKE_GPT2IMAGE_*`。每类至少提供 `BASE_URL` 和 `API_KEY`;Responses 场景还必须提供 `/responses` 顶层模型。可选覆盖图片 `MODEL`、`SIZE`、`QUALITY`。`BASE_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 URL。默认不触发计费请求,必须显式加 `-- --allow-billable`。可复制 `.env.real-smoke.example` 为未跟踪的 `.env.real-smoke.local`,再通过 `-- --env-file .env.real-smoke.local` 加载;shell 环境变量优先级高于 `--env-file`,`--env-file` 优先级高于 `.env.local`。
|
| 579 |
+
|
| 580 |
+
`npm run smoke:image-upstream-local` 会临时启动仓库内置 fixture,把 5 个独立场景全部指向本机 `/v1` 兼容服务,并调用同一个 `smoke:image-upstream-real -- --require-independent-targets --allow-billable` 门禁路径。该命令用于验证本项目的 final-gate 脚本、事件归一化和本地可复现环境;输出会标记 `local_fixture=true`。它不证明原版 new-api、gaoren/new-api、sub2api 或 GPT2Image 第三方部署当前可访问,真实验收仍需配置 `.env.real-smoke.local` 后运行真实上游门禁。
|
| 581 |
+
|
| 582 |
+
若只需要验证当前 `.env.local` 中的 `OPENAI_API_KEY` 或 `OPENAI_CHANNEL_N_*` 服务端渠道,可追加 `-- --include-server-channel`。该模式不会把服务端 API Key 写入表单或输出,真实执行仍需同时追加 `--allow-billable`;可覆盖 Images JSON、Images SSE、Responses JSON、Responses SSE、Agent 内部 Images SSE 和 Agent 内部 Responses SSE 场景。可用 `IMAGE_REAL_SMOKE_SERVER_MODEL`、`IMAGE_REAL_SMOKE_SERVER_SIZE`、`IMAGE_REAL_SMOKE_SERVER_QUALITY`、`IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL` 覆盖模型、尺寸、质量和 Responses 顶层模型。单场景默认超时 `240000ms`,可用 `--timeout-ms` 或 `IMAGE_REAL_SMOKE_TIMEOUT_MS` 调整。
|
| 583 |
+
|
| 584 |
+
dry-run 输出中的 `independent_targets` 会汇总必跑、已选、未选、已配置和缺失的独立真实上游场景,并给出最终门禁命令;`required_count` 和 `unselected_required_count` 用于区分必跑总数和未选择数量,`configuration_complete=true` 只表示 5 个必跑场景都已选中且配置齐全,不代表已经执行计费生图。顶层 `final_gate_satisfied=true` 才表示最终独立真实上游门禁已实际执行并通过。`missing_env_any` 表示每组任选一个环境变量即可补齐该缺失项。例如 `sub2api-responses-json` 的 `BASE_URL` 和 `API_KEY` 可单独配置 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`,也可以复用 `IMAGE_REAL_SMOKE_SUB2API_*`;它的 `/responses` 顶层模型必须使用 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL`,避免和图片模型 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL` 混淆。
|
| 585 |
+
|
| 586 |
+
最终验收独立真实上游时追加 `-- --require-independent-targets --allow-billable`。此时任一独立真实上游场景未被选中或被跳过都会让脚本以非零退出,并在 `unselected_required_cases`、`skipped_required_cases`、`missing_required_count` 和 `missing_required_cases` 中列出未完成的场景。若最终门禁预检发现必跑场景未选全、缺少配置或配置非法,脚本会阻断可运行目标并先失败,不触发任何真实上游计费调用。
|
| 587 |
+
|
| 588 |
+
`--include-server-channel` 只能验证当前 `.env.local` 服务端渠道,不能替代独立真实上游门禁。最终验收必须让以下 5 个独立场景都实际执行,且结果中 `skipped=false`:
|
| 589 |
+
|
| 590 |
+
| 场景 ID | 验证对象 | 必需配置 |
|
| 591 |
+
| --- | --- | --- |
|
| 592 |
+
| `original-images-json` | 原版 QuantumNous/new-api 兼容 Images API JSON | `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL`、`IMAGE_REAL_SMOKE_ORIGINAL_API_KEY` |
|
| 593 |
+
| `gaoren-images-sse` | gaoren002/new-api Images API SSE/keepalive 分支 | `IMAGE_REAL_SMOKE_GAOREN_BASE_URL`、`IMAGE_REAL_SMOKE_GAOREN_API_KEY` |
|
| 594 |
+
| `sub2api-images-sse` | Wei-Shaw/sub2api Images API SSE | `IMAGE_REAL_SMOKE_SUB2API_BASE_URL`、`IMAGE_REAL_SMOKE_SUB2API_API_KEY` |
|
| 595 |
+
| `sub2api-responses-json` | sub2api Responses image_generation bridge | `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY` 或复用 `IMAGE_REAL_SMOKE_SUB2API_BASE_URL`、`IMAGE_REAL_SMOKE_SUB2API_API_KEY`;另需 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL` |
|
| 596 |
+
| `gpt2image-responses-sse` | GPT2Image 风格 Responses image_generation SSE | `IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL`、`IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY`;另需 `IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL` |
|
| 597 |
+
|
| 598 |
## 常见问题
|
| 599 |
|
| 600 |
### 提示未检测到 Node.js
|
docker-compose.memory.yml
CHANGED
|
@@ -1,8 +1,12 @@
|
|
| 1 |
services:
|
| 2 |
gpt-image-playground:
|
|
|
|
|
|
|
|
|
|
| 3 |
environment:
|
| 4 |
AGENT_STATE_BACKEND: memory
|
| 5 |
AGENT_SQLITE_PATH: ""
|
| 6 |
AGENT_DATABASE_URL: ""
|
| 7 |
AGENT_DB_PASSWORD: ""
|
| 8 |
AGENT_DB_PASSWORD_FILE: ""
|
|
|
|
|
|
| 1 |
services:
|
| 2 |
gpt-image-playground:
|
| 3 |
+
build:
|
| 4 |
+
args:
|
| 5 |
+
NEXT_PUBLIC_IMAGE_STORAGE_MODE: indexeddb
|
| 6 |
environment:
|
| 7 |
AGENT_STATE_BACKEND: memory
|
| 8 |
AGENT_SQLITE_PATH: ""
|
| 9 |
AGENT_DATABASE_URL: ""
|
| 10 |
AGENT_DB_PASSWORD: ""
|
| 11 |
AGENT_DB_PASSWORD_FILE: ""
|
| 12 |
+
NEXT_PUBLIC_IMAGE_STORAGE_MODE: indexeddb
|
docs/deployment/huggingface-space-free.md
CHANGED
|
@@ -45,7 +45,7 @@ hf auth whoami
|
|
| 45 |
- npm 随 Node.js 一起可用。
|
| 46 |
- Hugging Face CLI 使用当前官方 `hf` 命令。
|
| 47 |
- `hf auth login` 使用 Hugging Face Access Token,不是账号密码。
|
| 48 |
-
- Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;
|
| 49 |
|
| 50 |
安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
|
| 51 |
|
|
@@ -58,10 +58,58 @@ npm install
|
|
| 58 |
如果不确定当前机器缺什么,运行只读诊断:
|
| 59 |
|
| 60 |
```bash
|
| 61 |
-
npm run doctor
|
| 62 |
```
|
| 63 |
|
| 64 |
-
`doctor
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
## Space Variables
|
| 67 |
|
|
@@ -160,6 +208,7 @@ npm run smoke:hf-space
|
|
| 160 |
- 以 `AGENT_STATE_BACKEND=memory` 启动临时容器。
|
| 161 |
- 用手机 User-Agent 检查首页可访问。
|
| 162 |
- 检查 `/api/agent/capabilities` 返回 `state_backend=memory` 和 `image_storage_mode=indexeddb`。
|
|
|
|
| 163 |
- 执行 Agent 生成和编辑脚本的契约检查,不触发真实上游生图。
|
| 164 |
|
| 165 |
## 免费层限制
|
|
@@ -195,110 +244,6 @@ npm run keepalive:hf-space
|
|
| 195 |
|
| 196 |
注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。
|
| 197 |
|
| 198 |
-
## 初始化本机访问记录
|
| 199 |
-
|
| 200 |
-
不同用户首次接手自己的 Space 时,先在本机生成访问记录文件。该文件保存在用户 home 目录下,不应提交到仓库:
|
| 201 |
-
|
| 202 |
-
```bash
|
| 203 |
-
npm run init-access:hf-space -- \
|
| 204 |
-
--space-id <namespace>/<space-name> \
|
| 205 |
-
--space-url https://<user>-<space>.hf.space
|
| 206 |
-
```
|
| 207 |
-
|
| 208 |
-
默认写入:
|
| 209 |
-
|
| 210 |
-
```text
|
| 211 |
-
~/.cache/gpt-image-playground-customer/hf-space-access.txt
|
| 212 |
-
```
|
| 213 |
-
|
| 214 |
-
文件会包含:
|
| 215 |
-
|
| 216 |
-
```dotenv
|
| 217 |
-
HF_SPACE_ID=<namespace>/<space-name>
|
| 218 |
-
HF_SPACE_URL=https://<user>-<space>.hf.space
|
| 219 |
-
HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN
|
| 220 |
-
APP_PASSWORD=<generated-page-access-code>
|
| 221 |
-
AGENT_API_TOKEN=<generated-agent-token>
|
| 222 |
-
```
|
| 223 |
-
|
| 224 |
-
`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向���理、自定义域名或普通示例域名。
|
| 225 |
-
|
| 226 |
-
脚本不会在输出中回显 `APP_PASSWORD` 或 `AGENT_API_TOKEN`。如果文件已存在,默认拒绝覆盖;确认要重置时使用:
|
| 227 |
-
|
| 228 |
-
```bash
|
| 229 |
-
npm run init-access:hf-space -- \
|
| 230 |
-
--space-id <namespace>/<space-name> \
|
| 231 |
-
--space-url https://<user>-<space>.hf.space \
|
| 232 |
-
--force
|
| 233 |
-
```
|
| 234 |
-
|
| 235 |
-
创建这个 txt 文件不需要 Hugging Face 账号密码。它只保存本项目的访问码、Agent token 和 Space 目标信息。
|
| 236 |
-
|
| 237 |
-
同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token。先检查登录状态:
|
| 238 |
-
|
| 239 |
-
```bash
|
| 240 |
-
hf auth whoami
|
| 241 |
-
```
|
| 242 |
-
|
| 243 |
-
如果未登录,执行:
|
| 244 |
-
|
| 245 |
-
```bash
|
| 246 |
-
hf auth login
|
| 247 |
-
```
|
| 248 |
-
|
| 249 |
-
`hf auth login` 使用的是 Hugging Face Access Token,不是账号密码。不要把 HF 账号密码或 HF Access Token 写入 `hf-space-access.txt`。
|
| 250 |
-
|
| 251 |
-
生成后可先做本机只读诊断,不写远端:
|
| 252 |
-
|
| 253 |
-
```bash
|
| 254 |
-
npm run doctor:hf-space -- --skip-remote
|
| 255 |
-
```
|
| 256 |
-
|
| 257 |
-
如果诊断提示 access 文件缺少 `HF_SPACE_ID`、`HF_SPACE_URL` 或 `HF_SPACE_SECRET_KEYS`,说明本机可能已有旧格式文件。可手工补齐这些字段,或确认重置后重新生成:
|
| 258 |
-
|
| 259 |
-
```bash
|
| 260 |
-
npm run init-access:hf-space -- \
|
| 261 |
-
--space-id <namespace>/<space-name> \
|
| 262 |
-
--space-url https://<user>-<space>.hf.space \
|
| 263 |
-
--force
|
| 264 |
-
```
|
| 265 |
-
|
| 266 |
-
## 同步本机访问码到 Space
|
| 267 |
-
|
| 268 |
-
如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新访问码:
|
| 269 |
-
|
| 270 |
-
```bash
|
| 271 |
-
npm run sync-secret:hf-space
|
| 272 |
-
```
|
| 273 |
-
|
| 274 |
-
默认读取:
|
| 275 |
-
|
| 276 |
-
```text
|
| 277 |
-
~/.cache/gpt-image-playground-customer/hf-space-access.txt
|
| 278 |
-
```
|
| 279 |
-
|
| 280 |
-
由 `init-access:hf-space` 生成的文件会让同步脚本同时同步 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。旧格式文件默认只同步 `APP_PASSWORD`,不会在输出中回显访问码值。可通过环境变量覆盖目标或同步多个 key:
|
| 281 |
-
|
| 282 |
-
```bash
|
| 283 |
-
HF_SPACE_ID=misonL/gpt-image-playground-customer \
|
| 284 |
-
HF_SPACE_URL=https://misonl-gpt-image-playground-customer.hf.space \
|
| 285 |
-
HF_SPACE_ACCESS_FILE=~/.cache/gpt-image-playground-customer/hf-space-access.txt \
|
| 286 |
-
HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN \
|
| 287 |
-
npm run sync-secret:hf-space
|
| 288 |
-
```
|
| 289 |
-
|
| 290 |
-
同步脚本默认要求 `HF_SPACE_ID` 和 `HF_SPACE_URL` 来自 access 文件或环境变量,避免不同用户误写到仓库示例 Space。只有维护默认示例 Space 时才使用:
|
| 291 |
-
|
| 292 |
-
```bash
|
| 293 |
-
npm run sync-secret:hf-space -- --use-default-target
|
| 294 |
-
```
|
| 295 |
-
|
| 296 |
-
可选参数:
|
| 297 |
-
|
| 298 |
-
- `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。
|
| 299 |
-
- `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 访问码验证。
|
| 300 |
-
- `npm run sync-secret:hf-space -- --use-default-target`:允许使用脚本内置默认 Space 目标。
|
| 301 |
-
|
| 302 |
## 验证门禁
|
| 303 |
|
| 304 |
最小验证:
|
|
@@ -315,7 +260,7 @@ git diff --check
|
|
| 315 |
|
| 316 |
真实 Hugging Face gate:
|
| 317 |
|
| 318 |
-
1.
|
| 319 |
2. 手机打开 Space 页面,确认能进入页面并发起一次真实生成。
|
| 320 |
3. 电脑执行 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 契约检查。
|
| 321 |
4. 如有可用测试额度,再执行一次真实 Agent 生成。
|
|
|
|
| 45 |
- npm 随 Node.js 一起可用。
|
| 46 |
- Hugging Face CLI 使用当前官方 `hf` 命令。
|
| 47 |
- `hf auth login` 使用 Hugging Face Access Token,不是账号密码。
|
| 48 |
+
- Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;部署到远端 Space 使用 `hf` CLI。
|
| 49 |
|
| 50 |
安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
|
| 51 |
|
|
|
|
| 58 |
如果不确定当前机器缺什么,运行只读诊断:
|
| 59 |
|
| 60 |
```bash
|
| 61 |
+
npm run doctor
|
| 62 |
```
|
| 63 |
|
| 64 |
+
`doctor` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端 Variables 和远端 Secrets。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
|
| 65 |
+
|
| 66 |
+
## 管理员命令中心
|
| 67 |
+
|
| 68 |
+
本仓库只保留一组稳定管理员入口:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
npm run status
|
| 72 |
+
npm run doctor
|
| 73 |
+
npm run verify
|
| 74 |
+
npm run deploy:local
|
| 75 |
+
npm run deploy:space
|
| 76 |
+
npm run agent:doctor
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
- `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。
|
| 80 |
+
- `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查。
|
| 81 |
+
- `verify`:提交前基线,执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。
|
| 82 |
+
- `deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效。
|
| 83 |
+
- `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。
|
| 84 |
+
- `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。
|
| 85 |
+
|
| 86 |
+
HF Space 交互使用官方 `hf` CLI。不要维护本机 access 文件,不要把 `APP_PASSWORD`、`AGENT_API_TOKEN`、OpenAI Key 或 Hugging Face token 写入仓库文件。
|
| 87 |
+
|
| 88 |
+
部署当前干净的 git HEAD 到固定 Space:
|
| 89 |
+
|
| 90 |
+
```bash
|
| 91 |
+
npm run deploy:space
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
该脚本会:
|
| 95 |
+
|
| 96 |
+
- 使用 `git status --porcelain` 拒绝脏工作区。
|
| 97 |
+
- 使用 `git archive HEAD` 生成临时源码目录,只上传已跟踪源码。
|
| 98 |
+
- 使用 `hf upload` 上传到 `misonL/gpt-image-playground-customer`。
|
| 99 |
+
- 等待新 Space commit 进入 `RUNNING`。
|
| 100 |
+
- 检查 `/api/auth-status`、`/api/agent/capabilities` 和 `/api/runtime-capabilities`,不触发真实生图。
|
| 101 |
+
|
| 102 |
+
配置或轮换 Variables/Secrets 时,直接使用官方 `hf` CLI:
|
| 103 |
+
|
| 104 |
+
```bash
|
| 105 |
+
hf spaces variables add misonL/gpt-image-playground-customer -e AGENT_STATE_BACKEND=memory
|
| 106 |
+
hf spaces variables add misonL/gpt-image-playground-customer -e NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
|
| 107 |
+
hf spaces variables add misonL/gpt-image-playground-customer -e APP_LOG_LEVEL=warn
|
| 108 |
+
hf spaces secrets add misonL/gpt-image-playground-customer -s APP_PASSWORD=<page-access-code>
|
| 109 |
+
hf spaces secrets add misonL/gpt-image-playground-customer -s AGENT_API_TOKEN=<long-random-agent-token>
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
源码部署、远端诊断、Variables 和 Secrets 都围绕 `hf` CLI 完成;仓库内不再提供第二套 access-file 同步流程。
|
| 113 |
|
| 114 |
## Space Variables
|
| 115 |
|
|
|
|
| 208 |
- 以 `AGENT_STATE_BACKEND=memory` 启动临时容器。
|
| 209 |
- 用手机 User-Agent 检查首页可访问。
|
| 210 |
- 检查 `/api/agent/capabilities` 返回 `state_backend=memory` 和 `image_storage_mode=indexeddb`。
|
| 211 |
+
- 默认等待容器 HTTP ready 最多 45 秒;慢机器可设置 `HF_SPACE_SMOKE_READY_TIMEOUT_MS=90000`。
|
| 212 |
- 执行 Agent 生成和编辑脚本的契约检查,不触发真实上游生图。
|
| 213 |
|
| 214 |
## 免费层限制
|
|
|
|
| 244 |
|
| 245 |
注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
## 验证门禁
|
| 248 |
|
| 249 |
最小验证:
|
|
|
|
| 260 |
|
| 261 |
真实 Hugging Face gate:
|
| 262 |
|
| 263 |
+
1. 提交代码后执行 `npm run deploy:space`,等待 Space 新 commit 进入 `RUNNING`。
|
| 264 |
2. 手机打开 Space 页面,确认能进入页面并发起一次真实生成。
|
| 265 |
3. 电脑执行 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 契约检查。
|
| 266 |
4. 如有可用测试额度,再执行一次真实 Agent 生成。
|
docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22
|
| 2 |
+
|
| 3 |
+
## 范围
|
| 4 |
+
|
| 5 |
+
- Agent 图片端点路由契约、错误诊断与终态失败语义。
|
| 6 |
+
- 本地图片上游 fixture 与 `smoke:image-upstream-real` final gate 复用路径。
|
| 7 |
+
- 本记录只证明本地 fixture、脚本门禁与当前代码契约;不证明第三方真实部署当前可访问。
|
| 8 |
+
|
| 9 |
+
## 审计结论
|
| 10 |
+
|
| 11 |
+
- `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。
|
| 12 |
+
- 高分辨率 Agent edit 请求在 route 层 `snapshotAgentEditFormData` 前被拒绝,服务层也在读取服务端 API 凭据前保留二道校验。
|
| 13 |
+
- partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。
|
| 14 |
+
- 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。
|
| 15 |
+
- 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。
|
| 16 |
+
- 本地 final gate 会跳过 `.env.local` 加载,并清理 `IMAGE_REAL_SMOKE_*`、`OPENAI_*`、`APP_PASSWORD`、`AGENT_API_TOKEN` 等外层输入,避免 shell 凭据污染本地 fixture 验证。
|
| 17 |
+
|
| 18 |
+
## 验证记录
|
| 19 |
+
|
| 20 |
+
| 命令 | 退出码 | 摘要 |
|
| 21 |
+
| --- | --- | --- |
|
| 22 |
+
| `npm test` | 0 | 462 个测试通过;PostgreSQL live 子套件因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 |
|
| 23 |
+
| `npm run lint` | 0 | `eslint src` 通过。 |
|
| 24 |
+
| `npm run lint:scripts` | 0 | 脚本语法检查通过。 |
|
| 25 |
+
| `npm run build` | 0 | Next.js production build 通过,standalone runtime patch 完成。 |
|
| 26 |
+
| `node scripts/smoke-image-upstream-local-final-gate.mjs --timeout-ms 30000` | 0 | 5 个独立本地 fixture 场景全部通过,`final_gate_satisfied=true`。 |
|
| 27 |
+
| `git diff --check` | 0 | 当前 diff 无 whitespace error。 |
|
| 28 |
+
| 装饰符扫描 | 0 | 代码与 Markdown 改动中未发现 AGENTS.md 禁止的装饰性 Unicode 符号。 |
|
| 29 |
+
| `coderabbit review --prompt-only -t uncommitted` | 0 | CodeRabbit 返回 `findings=0`。 |
|
| 30 |
+
|
| 31 |
+
## 剩余边界
|
| 32 |
+
|
| 33 |
+
- 本轮未运行真实第三方上游 `--allow-billable` 门禁;独立真实上游仍需要 `.env.real-smoke.local` 提供 5 类真实目标后再跑 `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable`。
|
| 34 |
+
- 本轮未运行 live PostgreSQL gate;数据库真实行为仍以 `npm run test:postgres` 或 `npm run verify -- --postgres` 为准。
|
docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# CR-IMAGE-UPSTREAM-COMPAT-2026-05-21
|
| 2 |
+
|
| 3 |
+
## 范围
|
| 4 |
+
|
| 5 |
+
- 多上游图片生成兼容层阶段验证。
|
| 6 |
+
- 区分本地 mock 覆盖、当前真实上游 smoke、以及本机未配置的上游类型。
|
| 7 |
+
- 记录 2026-05-21 在 `codex/image-upstream-compat` 分支上的验证口径。
|
| 8 |
+
|
| 9 |
+
## 当前真实上游范围
|
| 10 |
+
|
| 11 |
+
`.env.local` 当前只配置了 `superapi.buzz` 这一类真实上游,共 7 个服务端渠道。没有可直接区分的原版 QuantumNous/new-api、gaoren002/new-api、Wei-Shaw/sub2api 或独立 GPT2Image 部署地址。因此“原版 new-api / gaoren / sub2api / GPT2Image”差异主要由本地契约测试覆盖,真实 smoke 只能证明当前 `superapi.buzz` 上游在对应协议路径下的表现。
|
| 12 |
+
|
| 13 |
+
当前独立真实上游配置检查:`.env.local` 没有 `IMAGE_REAL_SMOKE_*` 键,`.env.real-smoke.local` 不存在。仓库提供 `.env.real-smoke.example` 作为可提交模板,真实凭据仍应写入未跟踪的 `.env.real-smoke.local`。`npm run smoke:image-upstream-real` 当前返回 `independent_targets.configured_count=0`、`missing_count=5`。`npm run smoke:image-upstream-real -- --require-independent-targets` 当前以退出码 `1` 按预期失败,`skipped_required_cases` 为 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。
|
| 14 |
+
|
| 15 |
+
## 真实 smoke 结果
|
| 16 |
+
|
| 17 |
+
运行方式:在当前 worktree 启动 `npx next dev --turbopack -p 4784`;Responses 路径使用 `ENABLE_RESPONSES_IMAGE_BACKEND=true OPENAI_RESPONSES_API_MODEL=gpt-5.4` 重启同端口服务。所有输出均只记录状态、事件类型、图片数量和 base64 长度,不记录 API key 或完整图片数据。
|
| 18 |
+
|
| 19 |
+
| 场景 | 结果 | 证据摘要 |
|
| 20 |
+
| --- | --- | --- |
|
| 21 |
+
| Images API 普通 JSON | 通过 | `/api/images` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1045668`,耗时 `19921ms`。 |
|
| 22 |
+
| Images API SSE / keepalive 策略 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, completed, done`,`done_image_count=1`,耗时 `29615ms`。 |
|
| 23 |
+
| Responses image_generation 非流式 | 当前真实上游未通过 | `/api/images` 返回 `500 application/json`,错误为 `Responses API 未返回已完成的 image_generation_call.result。`。该结果说明当前真实上游的非流式 `/responses` 返回结构不满足本项目 final image 契约。 |
|
| 24 |
+
| Responses image_generation SSE 初测 | 暴露缺口后已修复 | 初测事件为 `partial_image, completed, error`,错误为 `response.output_item.done` 缺少图片 payload。修复后无图的 Responses output item done 会被忽略,仍要求整条流最终有 final image。 |
|
| 25 |
+
| Responses image_generation SSE 去重复测 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, completed, done`,`done_image_count=1`,耗时 `46624ms`。 |
|
| 26 |
+
| Agent 内部 Images SSE | 通过 | `/api/agent/images/generate` 返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未返回客户端 SSE,耗时 `96081ms`。 |
|
| 27 |
+
|
| 28 |
+
## 2026-05-22 脚本化真实复验
|
| 29 |
+
|
| 30 |
+
运行方式:`npm run smoke:image-upstream-real -- --include-server-channel` 先做 dry-run,确认 5 个独立真实目标因缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过,当前 `.env.local` 服务端渠道只在追加 `--allow-billable` 后执行。随后使用 `--timeout-ms 180000` 对当前服务端渠道逐项真实调用。输出只记录 host、状态、事件类型、数量和 base64 长度。
|
| 31 |
+
|
| 32 |
+
| 场景 | 结果 | 证据摘要 |
|
| 33 |
+
| --- | --- | --- |
|
| 34 |
+
| 当前服务端渠道 Images JSON 首次 | 显式失败 | `server-channel-images-json` 返回 `500 application/json`,`error=Connection error.`,耗时 `186537ms`,无图片。 |
|
| 35 |
+
| 当前服务端渠道 Images JSON 复测 | 通过 | `server-channel-images-json` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1332092`,耗时 `164893ms`。 |
|
| 36 |
+
| 当前服务端渠道 Images SSE | 通过 | `server-channel-images-sse` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, partial_image, completed, done`,`done_image_count=1`,`first_b64_length=1573772`,耗时 `10843ms`。 |
|
| 37 |
+
| 当前服务端渠道 Responses SSE | 通过 | `server-channel-responses-sse` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, completed, done`,`done_image_count=1`,`first_b64_length=1157276`,耗时 `3232ms`。 |
|
| 38 |
+
| 当前服务端渠道 Agent 内部 Images SSE | 通过 | `server-channel-agent-images-sse` 通过 `npm run smoke:image-upstream-real -- --include-server-channel --allow-billable --timeout-ms 180000 --case server-channel-agent-images-sse` 验证,返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未内联 base64,耗时 `59243ms`。 |
|
| 39 |
+
| 当前服务端渠道 Agent 内部 Responses SSE | 通过 | `server-channel-agent-responses-sse` 通过 `npm run smoke:image-upstream-real -- --include-server-channel --allow-billable --timeout-ms 180000 --case server-channel-agent-responses-sse` 验证,返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未内联 base64,耗时 `19270ms`。 |
|
| 40 |
+
|
| 41 |
+
## 本地契约覆盖
|
| 42 |
+
|
| 43 |
+
- `npm run smoke:image-upstream-compat` 启动临时本地 mock 上游并直接调用当前 `/api/images` route,覆盖原版 new-api Images API JSON、sub2api Images API JSON、gaoren new-api Images SSE keepalive、gaoren JSON-as-SSE completed、sub2api Images SSE、sub2api Responses image_generation bridge JSON、GPT2Image Responses image_generation SSE。
|
| 44 |
+
- `npm run smoke:image-upstream-real` 提供真实上游 smoke 入口,默认只检查 `IMAGE_REAL_SMOKE_*` 配置,不加 `-- --allow-billable` 不会触发生图。当前运行结果为 5 个独立真实目标均跳过,原因是未配置独立真实上游 `BASE_URL` 环境变量。脚本已支持 `--include-server-channel`,可复用当前 `.env.local` 的服务端渠道跑 Images JSON、Images SSE、Responses JSON、Responses SSE、Agent 内部 Images SSE 和 Agent 内部 Responses SSE smoke,且不把服务端 API Key 写入表单或输出;单场景默认超时 `240000ms`。dry-run 会返回 `independent_targets` 和 `missing_env_any`,汇总必跑、已选、未选、已配置与缺失的独立目标,并用 `required_count`、`unselected_required_count`、`configuration_complete` 和顶层 `final_gate_satisfied` 明确 5 个必跑场景是否全部已实际执行并通过;非计费阶段会拒绝带凭据、查询参数或片段的 `BASE_URL`;最终验收可加 `--require-independent-targets --allow-billable`,让任何独立真实上游未被选中或被跳过都以非零退出,并写入 `unselected_required_cases`、`skipped_required_cases`、`missing_required_count` 和 `missing_required_cases`。脚本支持 `--env-file <path>` 加载独立真实 smoke 凭据文件,shell 环境变量优先级高于 `--env-file`,`--env-file` 优先级高于 `.env.local`,便于把原版 new-api、gaoren、sub2api、GPT2Image 的真实目标与主服务渠道配置隔离。`.env.real-smoke.example` 是可提交模板,`.env.real-smoke.local` 继续被 `.gitignore` 排除。`scripts/smoke-image-upstream-real.test.mjs` 覆盖默认非计费、配置后仍需显式授权、当前服务端渠道 dry-run 不泄漏 API key、独立上游准备度摘要、独立上游必跑门禁、缺失 env 诊断、unsafe `BASE_URL` 预检、超时参数校验、未知场景显式失败、显式 env 文件加载优先级、测试隔离 `.env.local` 私有渠道配置,以及本地 billable Agent smoke 后清理 `generated-images/.real-smoke` 新增图片产物。
|
| 45 |
+
- `src/lib/image-stream-events.test.ts` 覆盖 OpenAI Images、OtokAPI、Responses partial、Responses partial `b64_json` 兼容字段、Responses image_generation_call completed marker、顶层 completed result、Responses output item done、Responses completed、远程 URL-only 显式失败、`response.failed` 显式失败、`response.completed` 内 `image_generation_call.status=failed` 显式失败、`image_generation_call.status=failed` 显式失败、keepalive/非对象忽略。
|
| 46 |
+
- `src/lib/responses-image-backend.test.ts` 覆盖 Responses image_generation 非流式后端:读取 `image_generation_call.result`、接受省略 `status` 但提供 `result` 的兼容响应、拒绝远程 URL-only 结果、显式暴露 failed `image_generation_call` 错误、提取 data URL base64、以及流式请求参数。
|
| 47 |
+
- `src/app/api/images/route.test.ts` 覆盖 Images API 非流式 JSON、Images API SSE、Images API 与 Responses image_generation 下 `force-sse` 在请求省略旧 `stream` 字段时仍进入上游 SSE、gaoren JSON-as-SSE completed 包装、SDK/relay 包装 SSE、stream 断开错误、Images API 非流式远程 URL-only 显式 502 失败、Responses 后端非流式、Responses 后端 SSE、以及 Responses failed `image_generation_call` 在 JSON/SSE 两条页面路径上都返回稳定 502 错误契约。
|
| 48 |
+
- `src/app/api/agent/agent-routes.test.ts` 覆盖 Agent 默认最终 JSON、`streaming_strategy=off` 不发送上游流式参数、Images API 与 Responses image_generation 下 `streaming_strategy=force-sse` 发送上游 stream 但对外仍返回最终 JSON、直接 generate 内部 Images SSE 消费、直接 generate 内部 Responses image_generation SSE 消费、Responses failed `image_generation_call` 归一化为 `upstream_unavailable`、job polling 内部 Images SSE 消费并保存最终 artifact、job polling 内部 Responses image_generation SSE 消费并保存最终 artifact、直接 generate 与 job polling 的 Images API / Responses image_generation partial-only 无 final image 失败。
|
| 49 |
+
- `src/lib/agent-api-contracts.test.ts` 覆盖 capabilities/OpenAPI 中页面 SSE、Agent 内部 upstream SSE、最终响应契约、后端枚举、流式策略枚举、真正启用上游 SSE 的 activation 策略和默认非流式 Agent 策略。
|
| 50 |
+
- `src/lib/image-stream-service.test.ts` 和 `src/lib/image-stream-collector.test.ts` 覆盖 Responses 流中同一 final image 跨事件重复到达、以及单个完成事件同时经 SDK/Responses 包装层重复抽取时只保存一份最终产物;同一事件内合法多图结果仍保留多张图片。
|
| 51 |
+
- 浏览器 UI smoke:`npm run dev -- --port 4785` 启动页面后确认默认未勾选流式预览;高级参数展示 Images API / Responses image_generation 后端与 6 个流式策略;4K/high + auto 显示流式建议;Responses 后端显示“Responses 顶层模型”;注入本地 fetch SSE keepalive stub 后,持续 keepalive 流只显示“连接保持中...”,不生成预览或成功结果。快速关闭且无 final image 的 keepalive 流会显式失败为“API 响应中没有有效图片数据或文件名。”,不伪造成功。2026-05-22 复验中,浏览器实际提交字段包含 `stream=true`、`partial_images=1`、`size=3072x2048`、`quality=high`、`image_backend=responses-image-generation`、`image_streaming_strategy=auto`;页面 DOM 中 `document.images` 为空,最终显示上述显式错误。
|
| 52 |
+
- 运行态 Agent contract smoke:`npm run dev -- --port 4785` 启动后,`GET /api/agent/capabilities` 返回 `defaults.streaming_strategy=off`、`agent_streaming.generate.mode=non_streaming_only`、`agent_streaming.upstream_sse.mode=internal_upstream_sse`、`final_response_contract=AgentImageResponse`,并列出 `image_backend`、`streaming_strategy`、`partial_images` 三个内部上游 SSE 请求字段;`GET /api/agent/openapi.json` 的 `GenerateRequest` schema 同样包含这三个字段,`AgentStreamingCapabilities.upstream_sse.request_fields` 与 capabilities 一致;`GET /api/runtime-capabilities` 当前显示 `responsesImageBackend.enabled=false`、`mode=experimental`。
|
| 53 |
+
|
| 54 |
+
## 2026-05-22 当前 worktree 基线复验
|
| 55 |
+
|
| 56 |
+
本轮补充复核:`npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`npm run smoke:image-upstream-compat`、`git diff --check` 均重新通过。`npm run smoke:image-upstream-real -- --include-server-channel` 以非计费 dry-run 通过;`npm run smoke:image-upstream-real -- --require-independent-targets` 继续按预期以退出码 `1` 失败,原因是 `.env.real-smoke.local` 不存在且 5 个独立真实上游目标均缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL`。本轮收尾未追加新的 `--allow-billable` 请求;表中带 `--allow-billable` 的服务端渠道记录为同日此前已记录的真实服务端渠道证据,不等同于独立真实上游最终门禁。`generated-images/.real-smoke` 目录无产物残留。
|
| 57 |
+
|
| 58 |
+
| 命令 | 退出码 | 摘要 |
|
| 59 |
+
| --- | --- | --- |
|
| 60 |
+
| `npm test` | 0 | `425` 个测试通过,`0` 个失败。PostgreSQL live 测试因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 |
|
| 61 |
+
| `npm run lint` | 0 | `eslint src` 通过。 |
|
| 62 |
+
| `npm run lint:scripts` | 0 | `scripts/check-node-syntax.mjs` 通过。 |
|
| 63 |
+
| `npm run build` | 0 | Next.js 16.2.6 production build 通过,standalone runtime patch 完成。 |
|
| 64 |
+
| `npm run test:postgres` | 0 | 临时 `postgres:16-alpine` 容器内 55 个测试通过,覆盖 Agent route PostgreSQL 集成、Postgres schema/live concurrency、迁移、清理和 share metadata 契约。 |
|
| 65 |
+
| `npx tsc --noEmit` | 0 | 测试和源码 TypeScript 静态检查通过。 |
|
| 66 |
+
| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 |
|
| 67 |
+
| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 上游兼容场景全部通过。 |
|
| 68 |
+
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 20 个脚本测试通过,覆盖 help 输出列出所有独立真实上游 env 前缀、`--env-file`、当前服务端 Responses JSON 和 Agent Responses SSE dry-run、非计费阶段拒绝 unsafe `BASE_URL`、独立上游准备度摘要、显式 env 文件加载优先级、测试隔离 `.env.local` 私有渠道配置、本地 billable Agent smoke 后清理 `generated-images/.real-smoke` 新增图片产物、独立上游必跑/已选/未选场景报告、`--require-independent-targets --case ...` 子集运行不能误报通过、只选择 server-channel 场景时最终门禁不能误报通过、5 个独立目标全部实际跑通后 `final_gate_satisfied=true`,以及本地 mock billable Responses JSON 走 `/v1/responses` 并返回图片。 |
|
| 69 |
+
| `npm run smoke:image-upstream-real -- --help` | 0 | help 输出列出 `--env-file <path>`、`IMAGE_REAL_SMOKE_ORIGINAL_*`、`IMAGE_REAL_SMOKE_GAOREN_*`、`IMAGE_REAL_SMOKE_SUB2API_*`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`、`IMAGE_REAL_SMOKE_GPT2IMAGE_*`。 |
|
| 70 |
+
| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;`independent_targets.configured_count=0`、`missing_count=5`;5 个独立真实上游目标因缺少 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过;当前服务端渠道因缺少 `--allow-billable` 跳过,未触发真实生图。 |
|
| 71 |
+
| `npm run smoke:image-upstream-real -- --include-server-channel --case server-channel-responses-json` | 0 | 非计费 dry-run 通过;当前服务端 Responses JSON smoke 识别 `superapi.buzz` 渠道,因缺少 `--allow-billable` 跳过,未触发真实��图。 |
|
| 72 |
+
| `npm run smoke:image-upstream-real -- --include-server-channel --case server-channel-agent-responses-sse` | 0 | 非计费 dry-run 通过;当前服务端 Agent Responses SSE smoke 识别 `superapi.buzz` 渠道,因缺少 `--allow-billable` 跳过,未触发真实生图。 |
|
| 73 |
+
| `npm run smoke:image-upstream-real -- --include-server-channel --allow-billable --timeout-ms 180000 --case server-channel-agent-responses-sse` | 0 | 同日此前记录的真实服务端渠道通过;返回 `200 application/json`,`image_count=1`,`first_content_url=/api/agent/artifacts/.../content`,`has_inline_base64=false`。 |
|
| 74 |
+
| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 预期失败;`independent_targets.configured_count=0`、`missing_count=5`;`skipped_required_cases` 为 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 |
|
| 75 |
+
| `npm run smoke:image-upstream-real -- --env-file .env.real-smoke.example --require-independent-targets` | 1 | 预期失败;可提交模板中的空值不会被误判为已配置真实上游,5 个独立真实目标仍全部列入 `skipped_required_cases`。 |
|
| 76 |
+
| `git diff --check` | 0 | 当前 diff 无 whitespace error。 |
|
| 77 |
+
| `find generated-images/.real-smoke ...` | 0 | 当前 `generated-images/.real-smoke` 目录无新增 `png`、`jpg`、`jpeg`、`webp` 产物残留。 |
|
| 78 |
+
|
| 79 |
+
## 2026-05-22 运行态契约复验
|
| 80 |
+
|
| 81 |
+
运行方式:`npx next dev --turbopack -p 4785` 启动本地服务后,只读请求 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json`、`GET /api/runtime-capabilities`。
|
| 82 |
+
|
| 83 |
+
| 端点 | 结果 | 摘要 |
|
| 84 |
+
| --- | --- | --- |
|
| 85 |
+
| `/api/agent/capabilities` | 通过 | `defaults.image_backend=images-api`,`defaults.streaming_strategy=off`,`defaults.partial_images=2`;`agent_streaming.generate.mode=non_streaming_only`,`agent_streaming.edit.mode=non_streaming_only`,`agent_streaming.upstream_sse.mode=internal_upstream_sse`,`request_fields=image_backend,streaming_strategy,partial_images`,`activation_strategies=openai-sse,newapi-keepalive-sse,responses-sse,force-sse`,`final_response_contract=AgentImageResponse`;`agent_streaming.page_sse.endpoint=/api/images`,`contract=page_ui_only`;`agent_jobs.mode=job_polling`。 |
|
| 86 |
+
| `/api/agent/openapi.json` | 通过 | `GenerateRequest` 暴露 `image_backend`、`streaming_strategy`、`partial_images`;`image_backend.enum=images-api,responses-image-generation`;`streaming_strategy.enum=off,auto,openai-sse,newapi-keepalive-sse,responses-sse,force-sse`;`partial_images` 范围为 `1..3`;`AgentStreamingCapabilities.upstream_sse.final_response_contract` 只允许 `AgentImageResponse`。 |
|
| 87 |
+
| `/api/runtime-capabilities` | 通过 | `responsesImageBackend.enabled=false`,`mode=experimental`;运行态流式批量能力未默认开启,当前服务端渠道健康容量为 `healthyCredentialCount=7`、`healthyChannelCount=7`。 |
|
| 88 |
+
|
| 89 |
+
## 2026-05-22 推送后补充复验
|
| 90 |
+
|
| 91 |
+
当前 HEAD 为 `6fa48f1 Fix agent route test error code typing`,已推送到 `origin/codex/image-upstream-compat`。PR #7 仍为 Draft/Open,`mergeStateStatus=CLEAN`。本轮只修改 `src/app/api/agent/agent-routes.test.ts` 的测试桩类型,把持久化失败用例中的错误码收敛为 `AgentErrorCode`,不改变业务运行代码。
|
| 92 |
+
|
| 93 |
+
| 命令或检查 | 退出码 | 摘要 |
|
| 94 |
+
| --- | --- | --- |
|
| 95 |
+
| `npm run verify -- --postgres` | 0 | `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`npm run test:postgres`、`git diff --check`、`git diff --cached --check` 全部通过。 |
|
| 96 |
+
| `NODE_ENV=test node --test --import tsx src/app/api/agent/agent-routes.test.ts` | 0 | 37 个 Agent route 测试通过;PostgreSQL 子套件因该定向命令未配置 `AGENT_POSTGRES_TEST_DATABASE_URL` 跳过,完整 Postgres gate 已由 `npm run verify -- --postgres` 覆盖。 |
|
| 97 |
+
| `npx tsc --noEmit` | 0 | 修复后源码和测试 TypeScript 静态检查通过。 |
|
| 98 |
+
| `npm run smoke:image-upstream-compat` | 0 | 原版 new-api Images JSON、sub2api Images JSON、gaoren keepalive SSE、gaoren JSON-as-SSE、sub2api Images SSE、sub2api Responses bridge、GPT2Image Responses SSE 七个本地 mock 兼容场景通过。 |
|
| 99 |
+
| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 |
|
| 100 |
+
| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍未配置,当前 `.env.local` 服务端渠道因缺少 `--allow-billable` 未触发生图。 |
|
| 101 |
+
| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 按最终门禁预期失败;`final_gate_satisfied=false`、`missing_required_count=5`,缺少 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 |
|
| 102 |
+
| `npx next dev --turbopack -p 4786` + 浏览器复验 | 0 | `/api/agent/capabilities` 显示 Agent generate/edit 仍是 `non_streaming_only`,`upstream_sse.final_response_contract=AgentImageResponse`;高级参数显示 Images API / Responses image_generation 和 6 个流式策略;4K/high + auto 显示流式建议;keepalive-only SSE 期间 `document.images.length=0` 且只显示“连接保持中...”,关闭后显式报“API 响应中没有有效图片数据或文件名。”。 |
|
| 103 |
+
| `.env.real-smoke.local` / `.env.real-smoke.example` | 不适用 | `.env.real-smoke.local` 当前不存在;模板 `.env.real-smoke.example` 只包含空占位符和默认 `IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4`,不能满足最终真实门禁。 |
|
| 104 |
+
|
| 105 |
+
## 2026-05-22 status readiness 补充
|
| 106 |
+
|
| 107 |
+
在 `be9e7cb Read real smoke env files in status` 基础上继续补充 `npm run status` 的 env 文件读取与 URL 安全校验。`status` 现在按 shell 环境变量、`.env.real-smoke.local`、`.env.local` 的优先级只读判断独立真实上游 smoke 配置是否齐全;输出只包含场景 ID、配置数量、缺失 env 键、非法 env 键与原因、最终门禁命令,不输出 URL 或 API Key。`scripts/command-center.test.mjs` 已覆盖 `.env.local` 与 `.env.real-smoke.local` 合并、shell env 优先、sub2api Responses 复用 sub2api 配置、unsafe `BASE_URL` 不泄露值,以及输出不包含 URL/key。
|
| 108 |
+
|
| 109 |
+
在 `40d87f9 Harden image upstream status readiness` 基础上继续补充 `npm run smoke:image-upstream-real` 的结构化 readiness 失败报告。真实 smoke 脚本现在遇到 unsafe 独立上游 `BASE_URL` 时不再只把错误写到 stderr,而是在 JSON 报告中输出 `invalid_env`、`invalid_cases`、`invalid_required_cases`,同样只包含 env 键与 reason,不输出 URL 或 API Key。若已显式开启 `--allow-billable` 但任一选中目标存在 unsafe `BASE_URL`,脚本会在 readiness 阶段用顶层 `blocked_cases` 阻断其它已配置目标;最终门禁模式额外输出 `blocked_required_cases`,避免配置非法时产生部分真实上游调用。
|
| 110 |
+
|
| 111 |
+
| 命令或检查 | 退出码 | 摘要 |
|
| 112 |
+
| --- | --- | --- |
|
| 113 |
+
| `node --test scripts/command-center.test.mjs` | 0 | 22 个脚本测试通过。 |
|
| 114 |
+
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 22 个真实 smoke 脚本测试通过,新增覆盖 unsafe 独立上游 `BASE_URL` 的结构化 JSON 报告、非法配置时阻断其它 billable 上游调用,以及普通 billable smoke 顶层 `blocked_cases` 汇总。 |
|
| 115 |
+
| `npm run status` | 0 | `image_upstream_real_smoke.configuration_complete=false`、`configured_count=0`、`missing_count=5`,并列出 5 个独立真实上游目标缺失的 `BASE_URL` env。 |
|
| 116 |
+
| unsafe `BASE_URL` status 探针 | 0 | 临时注入含凭据、查询参数和片段的 `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL` 后,`status` 只输出 `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL` 与 `must_not_include_credentials`,不输出 URL、查询 token 或 API Key。 |
|
| 117 |
+
| unsafe `BASE_URL` real-smoke 探针 | 1 | 临时注入含凭据、查询参数和片段的 `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL` 后,`smoke:image-upstream-real` 返回 JSON;`invalid_required_cases=original-images-json`。同时配置一个可 billable 的 gaoren 目标时,输出 `blocked_required_cases=gaoren-images-sse` 并在 1 秒内退出,证明未进入上游调用;不输出 URL、查询 token 或 API Key。 |
|
| 118 |
+
| `npm run verify -- --postgres` | 0 | `npm test`、lint、script lint、build、live PostgreSQL gate、diff checks 全部通过。 |
|
| 119 |
+
| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 兼容场景通过。 |
|
| 120 |
+
| `npx tsc --noEmit` | 0 | TypeScript 静态检查通过。 |
|
| 121 |
+
| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 按最终门禁预期失败;仍缺 5 个独立真实上游目标,`final_gate_satisfied=false`。 |
|
| 122 |
+
|
| 123 |
+
## 2026-05-22 final gate preflight 补充
|
| 124 |
+
|
| 125 |
+
本轮基线基于 `fc55a26 Block incomplete real smoke final gate`,该提交已推送到 `origin/codex/image-upstream-compat`。本轮补齐 `--require-independent-targets --allow-billable` 的 readiness 安全边界:若最终独立真实上游门禁发现必跑场景未选全、缺少配置或配置非法,脚本会在加载 `/api/images` / `/api/agent/images/generate` route 之前阻断已配置的可运行目标,先输出结构化失败,不发任何真实上游计费请求。该边界避免“最终门禁必然失败但已产生部分真实计费请求”的状态。
|
| 126 |
+
|
| 127 |
+
| 命令或检查 | 退出码 | 摘要 |
|
| 128 |
+
| --- | --- | --- |
|
| 129 |
+
| `npm run smoke:image-upstream-compat` | 0 | 原版 new-api Images JSON、sub2api Images JSON、gaoren keepalive SSE、gaoren JSON-as-SSE、sub2api Images SSE、sub2api Responses bridge、GPT2Image Responses SSE 七个本地 mock 兼容场景通过。 |
|
| 130 |
+
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 23 个真实 smoke 脚本测试通过。新增覆盖最终独立门禁只配置一个目标但缺少其它必跑目标时,已配置目标被列入 `blocked_required_cases`,本地 mock 上游 `calls.length=0`。 |
|
| 131 |
+
| `IMAGE_REAL_SMOKE_SKIP_DOTENV=1 npm run smoke:image-upstream-real -- --require-independent-targets --allow-billable` | 1 | 预期失败;`configuration_complete=false`、`configured_count=0`、`missing_count=5`、`missing_required_count=5`。命令虽然带 `--allow-billable`,但由于独立目标配置不完整,仅输出缺失项报告,不进入上游调用路径。 |
|
| 132 |
+
|
| 133 |
+
## 2026-05-22 Responses readiness 模型口径补充
|
| 134 |
+
|
| 135 |
+
本轮继续修正真实 smoke readiness 对 Responses 顶层模型的判断:`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL` 是 sub2api Responses 场景的图片模型覆盖项,不能被当成 `/responses` 顶层模型。`npm run status` 与 `npm run smoke:image-upstream-real` 现在只接受 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL` 作为 sub2api Responses 顶层模型;GPT2Image 场景仍接受 `IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL` 或 `OPENAI_RESPONSES_API_MODEL`。
|
| 136 |
+
|
| 137 |
+
| 命令或检查 | 退出码 | 摘要 |
|
| 138 |
+
| --- | --- | --- |
|
| 139 |
+
| `node --test scripts/command-center.test.mjs` | 0 | 新增覆盖 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2` 不会让 status readiness 误判 sub2api Responses 场景配置完成。 |
|
| 140 |
+
| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 新增覆盖真实 smoke dry-run 同样不会把 sub2api Responses 图片模型变量当成 `/responses` 顶层模型。 |
|
| 141 |
+
| `npm run status` | 0 | 当前本轮工作树有未提交改动,`head=5c6e21b`;独立真实上游仍为 `configured_count=0`、`missing_count=5`,最终门禁命令仍指向 `--env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable`。 |
|
| 142 |
+
|
| 143 |
+
## 2026-05-22 GPT2Image Responses SSE 去重补充
|
| 144 |
+
|
| 145 |
+
本轮兼容 mock smoke 暴露 GPT2Image 风格 Responses SSE 会先返回 `response.output_item.done`,再在 `response.completed.response.output[]` 中重复同一个无 id 的 `image_generation_call.result`。事件归一化现在在 Responses image call 缺少 `id` / `item_id` / `call_id` 时,使用最终 base64 payload 作为内部 dedupe key;如果上游提供不同 id,即使 payload 相同仍会保留为不同最终图片。
|
| 146 |
+
|
| 147 |
+
| 命令或检查 | 退出码 | 摘要 |
|
| 148 |
+
| --- | --- | --- |
|
| 149 |
+
| `node --test --import tsx src/lib/image-stream-collector.test.ts` | 0 | 新增覆盖 Responses 上游缺少 image call id 时,`response.output_item.done` 与 `response.completed` 的同 payload final image 只落一张。 |
|
| 150 |
+
| `npm run smoke:image-upstream-compat` | 0 | GPT2Image Responses SSE 场景恢复为 `partial_image, completed, done`,不再出现重复 `completed`。 |
|
| 151 |
+
|
| 152 |
+
## 2026-05-22 fcdd698 基线复验
|
| 153 |
+
|
| 154 |
+
复验基线为 `fcdd698 Fix Responses upstream smoke readiness`,分支 `codex/image-upstream-compat` 已同步到 `origin/codex/image-upstream-compat`。本小节为后续追加的文档记录,不改变业务代码。PR #7 仍为 Draft/Open,`mergeStateStatus=CLEAN`,当前 GitGuardian Security Checks 通过。
|
| 155 |
+
|
| 156 |
+
| 命令或检查 | 退出码 | 摘要 |
|
| 157 |
+
| --- | --- | --- |
|
| 158 |
+
| `npm run status` | 0 | 当前仅有本审计文档改动;独立真实上游 readiness 为 `configured_count=0`、`missing_count=5`,缺少 `original-images-json`、`gaoren-images-sse`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。 |
|
| 159 |
+
| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 兼容场景通过:original Images JSON、sub2api Images JSON、gaoren keepalive SSE、gaoren JSON-as-SSE、sub2api Images SSE、sub2api Responses bridge、GPT2Image Responses SSE。 |
|
| 160 |
+
| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍因缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过,当前服务端渠道未触发真实生图。 |
|
| 161 |
+
| `IMAGE_REAL_SMOKE_SKIP_DOTENV=1 npm run smoke:image-upstream-real -- --require-independent-targets --allow-billable` | 1 | 预期失败;`configuration_complete=false`、`configured_count=0`、`missing_required_count=5`。配置不完整时先输出缺失报告,不进入上游调用路径。 |
|
| 162 |
+
| `npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable` | 1 | 最终独立真实上游门禁仍失败;`.env.real-smoke.local` 当前不存在,5 个必跑目标全部列入 `skipped_required_cases`。 |
|
| 163 |
+
| `npm run verify` | 0 | `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`、`git diff --cached --check` 全部通过。 |
|
| 164 |
+
| `find generated-images/.real-smoke ...` | 0 | 当前没有 `png`、`jpg`、`jpeg`、`webp` 真实 smoke 产物残留。 |
|
| 165 |
+
|
| 166 |
+
本轮没有追加 `--allow-billable` 的真实生图请求。最终完成判定仍缺独立真实上游凭据和地址,需要配置 `.env.real-smoke.local` 后跑通最终门禁命令。
|
| 167 |
+
|
| 168 |
+
## 完成度审计矩阵
|
| 169 |
+
|
| 170 |
+
| 要求 | 当前证据 | 状态 |
|
| 171 |
+
| --- | --- | --- |
|
| 172 |
+
| 默认配置不破坏 OpenAI Images API、原版 new-api 和 sub2api 普通 JSON 基线 | `src/app/api/images/route.test.ts`、`npm run smoke:image-upstream-compat` 的 original new-api Images API JSON 与 sub2api Images API JSON 场景、`server-channel-images-json` 真实复测 | 已覆盖 |
|
| 173 |
+
| 流式能力只通过配置、UI 开关、Agent 显式策略或探测确认启用 | `src/lib/image-upstream-strategy.ts`、`src/lib/image-upstream-strategy.test.ts`、浏览器 UI smoke 默认未勾选流式、Agent defaults 为 `streaming_strategy=off` | 已覆盖 |
|
| 174 |
+
| gaoren/new-api keepalive SSE 和 JSON-as-SSE 能归一化,keepalive 不产生假预览 | `npm run smoke:image-upstream-compat` 的 gaoren keepalive 与 JSON-as-SSE 场景、`src/lib/image-stream-events.test.ts` keepalive/非对象忽略、浏览器 keepalive stub | 已覆盖,真实独立 gaoren 地址缺失 |
|
| 175 |
+
| sub2api Images SSE 与 Responses bridge 能归一化 | `npm run smoke:image-upstream-compat` 的 sub2api Images SSE 和 sub2api Responses bridge JSON 场景、`src/lib/image-stream-events.test.ts` Responses partial/output/completed 覆盖 | 已覆盖,真实独立 sub2api 地址缺失 |
|
| 176 |
+
| GPT2Image 风格 `/v1/responses` + `image_generation` 工具流式结果可兼容 | `src/lib/responses-image-backend.test.ts`、`npm run smoke:image-upstream-compat` 的 GPT2Image Responses SSE 场景、`server-channel-responses-sse` 真实复验 | 已覆盖,真实独立 GPT2Image 地址缺失 |
|
| 177 |
+
| partial image 只能作为进度预览,最终必须等待 completed base64 | `src/lib/image-stream-service.ts`、`src/lib/image-stream-collector.ts`、`src/app/api/agent/agent-routes.test.ts` partial-only 失败用例 | 已覆盖 |
|
| 178 |
+
| 缺 final base64、远程 URL-only、上游断流、Responses failed image call 必须显式失败 | `src/lib/image-stream-events.test.ts`、`src/lib/responses-image-backend.test.ts`、`src/app/api/images/route.test.ts` 的非流式 Images URL-only 502、Responses JSON/SSE failed image call 用例、`src/app/api/agent/agent-routes.test.ts` 的 Agent upstream SSE failed image call 用例、`server-channel-images-json` 首次真实 `Connection error` 显式失败记录 | 已覆盖 |
|
| 179 |
+
| Agent API 对外保持最终 JSON,内部可消费上游 SSE 并保存 artifact | `src/app/api/agent/agent-routes.test.ts` 的直接 generate Images SSE、直接 generate Responses SSE、job polling Images SSE + artifact content、job polling Responses SSE + artifact content 用例,以及 `server-channel-agent-images-sse` 与 `server-channel-agent-responses-sse` 脚本化真实 smoke | 已覆盖 |
|
| 180 |
+
| capabilities/OpenAPI/skill 文档清楚区分页面 SSE、Agent 内部 upstream SSE 和最终响应契约 | `src/lib/agent-api-contracts.test.ts`、`src/lib/agent-openapi.ts`、`skills/gpt-image-playground-agent/SKILL.md`、`skills/gpt-image-playground-agent/references/api.md`、运行态 `GET /api/agent/capabilities` 和 `GET /api/agent/openapi.json` smoke | 已覆盖 |
|
| 181 |
+
| 三类独立上游真实 smoke:原版 new-api、gaoren/new-api、sub2api/GPT2Image | `scripts/smoke-image-upstream-real.mjs` 已支持独立 `IMAGE_REAL_SMOKE_*` 目标和 `--env-file <path>`;当前 dry-run 证明本机未配置专用 `BASE_URL`,`independent_targets` 汇总缺失目标并给出最终门禁命令,`missing_env_any` 指出缺失 env;`--require-independent-targets --allow-billable` 在配置不完整时会先失败并阻断部分真实调用;配置齐全后才允许最终门禁实际计费执行 | 未完成,缺少独立真实上游地址和 key |
|
| 182 |
+
|
| 183 |
+
## 结论
|
| 184 |
+
|
| 185 |
+
- 当前实现保持默认 Images API JSON 基线,不会自动按仓库名启用流式能力。
|
| 186 |
+
- 页面默认不发送 `stream=true`;用户显式开启流式后,在没有 partial image 前只显示连接保持状态,不把 keepalive 当成预览或成功。
|
| 187 |
+
- 当前真实上游证明 Images JSON、Images SSE、Responses SSE、Agent 内部上游 SSE 可通过本项目稳定契约落到最终产物。
|
| 188 |
+
- 当前真实上游的 Responses 非流式路径未返回符合契约的 `image_generation_call.result`,本项目按设计显式失败。
|
| 189 |
+
- 因本机没有独立原版 new-api、gaoren new-api、sub2api 和 GPT2Image 地址,无法把这四类实现分别做真实 smoke;对应兼容行为以本地 mock 契约测试作为当前证据。
|
package.json
CHANGED
|
@@ -10,11 +10,19 @@
|
|
| 10 |
"test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"",
|
| 11 |
"test:scripts": "node --test \"scripts/**/*.test.mjs\"",
|
| 12 |
"test:postgres": "node scripts/test-postgres-live.mjs",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"doctor:hf-space": "node scripts/doctor-hf-space.mjs",
|
| 14 |
-
"init-access:hf-space": "node scripts/init-hf-space-access.mjs",
|
| 15 |
"keepalive:hf-space": "node scripts/keepalive-hf-space.mjs",
|
| 16 |
-
"sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs",
|
| 17 |
"smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs",
|
|
|
|
|
|
|
|
|
|
| 18 |
"start": "node scripts/start-standalone.mjs",
|
| 19 |
"lint": "eslint src",
|
| 20 |
"lint:scripts": "node scripts/check-node-syntax.mjs",
|
|
|
|
| 10 |
"test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"",
|
| 11 |
"test:scripts": "node --test \"scripts/**/*.test.mjs\"",
|
| 12 |
"test:postgres": "node scripts/test-postgres-live.mjs",
|
| 13 |
+
"doctor": "node scripts/doctor.mjs",
|
| 14 |
+
"status": "node scripts/status.mjs",
|
| 15 |
+
"verify": "node scripts/verify.mjs",
|
| 16 |
+
"deploy:local": "node scripts/deploy-local.mjs",
|
| 17 |
+
"deploy:space": "npm run deploy:hf-space",
|
| 18 |
+
"agent:doctor": "node scripts/agent-doctor.mjs",
|
| 19 |
+
"deploy:hf-space": "node scripts/deploy-hf-space.mjs",
|
| 20 |
"doctor:hf-space": "node scripts/doctor-hf-space.mjs",
|
|
|
|
| 21 |
"keepalive:hf-space": "node scripts/keepalive-hf-space.mjs",
|
|
|
|
| 22 |
"smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs",
|
| 23 |
+
"smoke:image-upstream-compat": "node --import tsx scripts/smoke-image-upstream-compat.mjs",
|
| 24 |
+
"smoke:image-upstream-local": "node scripts/smoke-image-upstream-local-final-gate.mjs",
|
| 25 |
+
"smoke:image-upstream-real": "node --import tsx -- scripts/smoke-image-upstream-real.mjs",
|
| 26 |
"start": "node scripts/start-standalone.mjs",
|
| 27 |
"lint": "eslint src",
|
| 28 |
"lint:scripts": "node scripts/check-node-syntax.mjs",
|
scripts/agent-doctor.mjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
|
| 5 |
+
import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
|
| 6 |
+
|
| 7 |
+
const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
|
| 8 |
+
const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
|
| 9 |
+
|
| 10 |
+
export function buildAgentDoctorArgs() {
|
| 11 |
+
return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
function parseArgs(argv) {
|
| 15 |
+
const unknown = argv.find((arg) => !['--help', '-h'].includes(arg));
|
| 16 |
+
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 17 |
+
return {
|
| 18 |
+
help: argv.includes('--help') || argv.includes('-h')
|
| 19 |
+
};
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function printHelp() {
|
| 23 |
+
console.log(`Usage:
|
| 24 |
+
npm run agent:doctor
|
| 25 |
+
|
| 26 |
+
Environment:
|
| 27 |
+
GPT_IMAGE_PLAYGROUND_URL Service base URL, defaults to http://localhost:4783.
|
| 28 |
+
GPT_IMAGE_AGENT_TOKEN Bearer token when capabilities require bearer auth.
|
| 29 |
+
GPT_IMAGE_APP_PASSWORD_HASH Password hash when capabilities require page password auth.`);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function main() {
|
| 33 |
+
const options = parseArgs(process.argv.slice(2));
|
| 34 |
+
if (options.help) {
|
| 35 |
+
printHelp();
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
const result = runCommand(process.execPath, buildAgentDoctorArgs(), {
|
| 40 |
+
env: { ...process.env, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' },
|
| 41 |
+
timeoutMs: AGENT_DOCTOR_TIMEOUT_MS
|
| 42 |
+
});
|
| 43 |
+
if (!result.ok) {
|
| 44 |
+
printJson({ ok: false, command: 'agent:doctor', output: pickFailureOutput(result) });
|
| 45 |
+
process.exit(1);
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const body = result.stdout ? parseJsonPayload(result.stdout, 'agent contract check') : {};
|
| 49 |
+
printJson({ ok: true, command: 'agent:doctor', contract: body });
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
try {
|
| 53 |
+
if (isMainModule(import.meta.url, process.argv[1])) main();
|
| 54 |
+
} catch (error) {
|
| 55 |
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
| 56 |
+
process.exit(1);
|
| 57 |
+
}
|
scripts/agent-skill-scripts.test.mjs
CHANGED
|
@@ -76,6 +76,1026 @@ describe('Agent skill script argument validation', () => {
|
|
| 76 |
assert.equal(result.stderr.trim(), '');
|
| 77 |
});
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
it('shows edit help without validating unrelated env values', () => {
|
| 80 |
const result = runSkillScript('edit-image.mjs', ['--help'], {
|
| 81 |
GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc'
|
|
@@ -138,6 +1158,31 @@ describe('Agent skill script argument validation', () => {
|
|
| 138 |
}
|
| 139 |
);
|
| 140 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
});
|
| 142 |
|
| 143 |
function runSkillScript(filename, args, env = {}) {
|
|
@@ -148,7 +1193,7 @@ function runSkillScript(filename, args, env = {}) {
|
|
| 148 |
});
|
| 149 |
}
|
| 150 |
|
| 151 |
-
function runSkillScriptAsync(filename, args, env = {}) {
|
| 152 |
return new Promise((resolve) => {
|
| 153 |
const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], {
|
| 154 |
cwd: repoRoot,
|
|
@@ -157,6 +1202,13 @@ function runSkillScriptAsync(filename, args, env = {}) {
|
|
| 157 |
});
|
| 158 |
let stdout = '';
|
| 159 |
let stderr = '';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
child.stdout.setEncoding('utf8');
|
| 161 |
child.stderr.setEncoding('utf8');
|
| 162 |
child.stdout.on('data', (chunk) => {
|
|
@@ -165,8 +1217,9 @@ function runSkillScriptAsync(filename, args, env = {}) {
|
|
| 165 |
child.stderr.on('data', (chunk) => {
|
| 166 |
stderr += chunk;
|
| 167 |
});
|
| 168 |
-
child.on('close', (status) => {
|
| 169 |
-
|
|
|
|
| 170 |
});
|
| 171 |
});
|
| 172 |
}
|
|
@@ -183,3 +1236,17 @@ async function withServer(handler, run) {
|
|
| 183 |
await new Promise((resolve) => server.close(resolve));
|
| 184 |
}
|
| 185 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
assert.equal(result.stderr.trim(), '');
|
| 77 |
});
|
| 78 |
|
| 79 |
+
it('includes explicit upstream streaming options in generate dry-run requests', () => {
|
| 80 |
+
const result = runSkillScript('generate-image.mjs', [
|
| 81 |
+
'--image-backend',
|
| 82 |
+
'responses',
|
| 83 |
+
'--streaming-strategy',
|
| 84 |
+
'responses-sse',
|
| 85 |
+
'--partial-images',
|
| 86 |
+
'3',
|
| 87 |
+
'prompt'
|
| 88 |
+
]);
|
| 89 |
+
|
| 90 |
+
assert.equal(result.status, 0);
|
| 91 |
+
const body = JSON.parse(result.stdout);
|
| 92 |
+
assert.equal(body.request.image_backend, 'responses');
|
| 93 |
+
assert.equal(body.request.streaming_strategy, 'responses-sse');
|
| 94 |
+
assert.equal(body.request.partial_images, 3);
|
| 95 |
+
assert.equal(result.stderr.trim(), '');
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
it('prints page SSE routing guidance for high-resolution generate dry-runs', () => {
|
| 99 |
+
const result = runSkillScript('generate-image.mjs', ['--size', '3072x2048', '--quality', 'high', 'prompt']);
|
| 100 |
+
|
| 101 |
+
assert.equal(result.status, 0);
|
| 102 |
+
const body = JSON.parse(result.stdout);
|
| 103 |
+
assert.equal(body.endpoint, 'http://localhost:4783/api/images');
|
| 104 |
+
assert.equal(body.routing_guidance.recommended_endpoint, '/api/images');
|
| 105 |
+
assert.equal(body.routing_guidance.transport, 'page_sse');
|
| 106 |
+
assert.equal(body.routing_guidance.fallback_endpoint, '/api/agent/images/generate');
|
| 107 |
+
assert.equal(body.routing_guidance.fallback_mode, 'manual_after_diagnosis');
|
| 108 |
+
assert.equal(body.routing_guidance.reason.includes('max_edge>2048'), true);
|
| 109 |
+
assert.equal(result.stderr.trim(), '');
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
it('does not automatically fall back after a billable page SSE generate failure', async () => {
|
| 113 |
+
const requests = [];
|
| 114 |
+
await withServer(
|
| 115 |
+
(request, response) => {
|
| 116 |
+
requests.push({ method: request.method, url: request.url });
|
| 117 |
+
if (request.url === '/api/agent/capabilities') {
|
| 118 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 119 |
+
response.end(
|
| 120 |
+
JSON.stringify({
|
| 121 |
+
agent_streaming: {
|
| 122 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 123 |
+
},
|
| 124 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 125 |
+
})
|
| 126 |
+
);
|
| 127 |
+
return;
|
| 128 |
+
}
|
| 129 |
+
if (request.url === '/api/images') {
|
| 130 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 131 |
+
response.end('data: {"type":"error","error":"stream failed","status":502}\n\n');
|
| 132 |
+
return;
|
| 133 |
+
}
|
| 134 |
+
if (request.url === '/api/agent/images/generate') {
|
| 135 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 136 |
+
response.end(JSON.stringify({ error: 'unexpected fallback' }));
|
| 137 |
+
return;
|
| 138 |
+
}
|
| 139 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 140 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 141 |
+
},
|
| 142 |
+
async (baseUrl) => {
|
| 143 |
+
const result = await runSkillScriptAsync(
|
| 144 |
+
'generate-image.mjs',
|
| 145 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 146 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
assert.equal(result.status, 1);
|
| 150 |
+
assert.equal(result.stdout.trim(), '');
|
| 151 |
+
const body = JSON.parse(result.stderr);
|
| 152 |
+
assert.equal(body.error.code, 'page_sse_failed');
|
| 153 |
+
assert.match(body.error.message, /stream failed/);
|
| 154 |
+
assert.equal(body.routing.fallback_endpoint, '/api/agent/images/generate');
|
| 155 |
+
assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis');
|
| 156 |
+
assert.deepEqual(
|
| 157 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 158 |
+
['GET /api/agent/capabilities', 'POST /api/images']
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
);
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
it('reports non-SSE page validation failures as non-billable', async () => {
|
| 165 |
+
const requests = [];
|
| 166 |
+
await withServer(
|
| 167 |
+
(request, response) => {
|
| 168 |
+
requests.push({ method: request.method, url: request.url });
|
| 169 |
+
if (request.url === '/api/agent/capabilities') {
|
| 170 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 171 |
+
response.end(
|
| 172 |
+
JSON.stringify({
|
| 173 |
+
agent_streaming: {
|
| 174 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 175 |
+
},
|
| 176 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 177 |
+
})
|
| 178 |
+
);
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
if (request.url === '/api/images') {
|
| 182 |
+
response.writeHead(400, { 'content-type': 'application/json' });
|
| 183 |
+
response.end(JSON.stringify({ error: 'size 对 gpt-image-2 无效:宽高必须是 16 的倍数。' }));
|
| 184 |
+
return;
|
| 185 |
+
}
|
| 186 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 187 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 188 |
+
},
|
| 189 |
+
async (baseUrl) => {
|
| 190 |
+
const result = await runSkillScriptAsync(
|
| 191 |
+
'generate-image.mjs',
|
| 192 |
+
['--allow-billable', '--size', '2049x2048', '--quality', 'high', 'prompt'],
|
| 193 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 194 |
+
);
|
| 195 |
+
|
| 196 |
+
assert.equal(result.status, 1);
|
| 197 |
+
assert.equal(result.stdout.trim(), '');
|
| 198 |
+
const body = JSON.parse(result.stderr);
|
| 199 |
+
assert.equal(body.billable, false);
|
| 200 |
+
assert.equal(body.error.code, 'page_sse_request_rejected');
|
| 201 |
+
assert.equal(body.error.status, 400);
|
| 202 |
+
assert.match(body.error.message, /16 的倍数/);
|
| 203 |
+
assert.equal(body.routing.fallback_mode, 'fix_request_before_retry');
|
| 204 |
+
assert.deepEqual(
|
| 205 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 206 |
+
['GET /api/agent/capabilities', 'POST /api/images']
|
| 207 |
+
);
|
| 208 |
+
}
|
| 209 |
+
);
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
+
it('reports non-JSON page 4xx failures as non-billable request rejections', async () => {
|
| 213 |
+
await withServer(
|
| 214 |
+
(request, response) => {
|
| 215 |
+
if (request.url === '/api/agent/capabilities') {
|
| 216 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 217 |
+
response.end(
|
| 218 |
+
JSON.stringify({
|
| 219 |
+
agent_streaming: {
|
| 220 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 221 |
+
},
|
| 222 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 223 |
+
})
|
| 224 |
+
);
|
| 225 |
+
return;
|
| 226 |
+
}
|
| 227 |
+
if (request.url === '/api/images') {
|
| 228 |
+
response.writeHead(401, { 'content-type': 'text/plain' });
|
| 229 |
+
response.end('missing passwordHash');
|
| 230 |
+
return;
|
| 231 |
+
}
|
| 232 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 233 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 234 |
+
},
|
| 235 |
+
async (baseUrl) => {
|
| 236 |
+
const result = await runSkillScriptAsync(
|
| 237 |
+
'generate-image.mjs',
|
| 238 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 239 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 240 |
+
);
|
| 241 |
+
|
| 242 |
+
assert.equal(result.status, 1);
|
| 243 |
+
assert.equal(result.stdout.trim(), '');
|
| 244 |
+
const body = JSON.parse(result.stderr);
|
| 245 |
+
assert.equal(body.billable, false);
|
| 246 |
+
assert.equal(body.error.code, 'page_sse_request_rejected');
|
| 247 |
+
assert.equal(body.error.status, 401);
|
| 248 |
+
assert.match(body.error.message, /missing passwordHash/);
|
| 249 |
+
assert.equal(body.routing.fallback_mode, 'fix_request_before_retry');
|
| 250 |
+
}
|
| 251 |
+
);
|
| 252 |
+
});
|
| 253 |
+
|
| 254 |
+
it('reports non-SSE page server failures as billable page SSE failures', async () => {
|
| 255 |
+
await withServer(
|
| 256 |
+
(request, response) => {
|
| 257 |
+
if (request.url === '/api/agent/capabilities') {
|
| 258 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 259 |
+
response.end(
|
| 260 |
+
JSON.stringify({
|
| 261 |
+
agent_streaming: {
|
| 262 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 263 |
+
},
|
| 264 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 265 |
+
})
|
| 266 |
+
);
|
| 267 |
+
return;
|
| 268 |
+
}
|
| 269 |
+
if (request.url === '/api/images') {
|
| 270 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 271 |
+
response.end(JSON.stringify({ error: { message: 'upstream page failed' } }));
|
| 272 |
+
return;
|
| 273 |
+
}
|
| 274 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 275 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 276 |
+
},
|
| 277 |
+
async (baseUrl) => {
|
| 278 |
+
const result = await runSkillScriptAsync(
|
| 279 |
+
'generate-image.mjs',
|
| 280 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 281 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 282 |
+
);
|
| 283 |
+
|
| 284 |
+
assert.equal(result.status, 1);
|
| 285 |
+
assert.equal(result.stdout.trim(), '');
|
| 286 |
+
const body = JSON.parse(result.stderr);
|
| 287 |
+
assert.equal(body.billable, true);
|
| 288 |
+
assert.equal(body.error.code, 'page_sse_failed');
|
| 289 |
+
assert.equal(body.error.status, 500);
|
| 290 |
+
assert.match(body.error.message, /upstream page failed/);
|
| 291 |
+
assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis');
|
| 292 |
+
}
|
| 293 |
+
);
|
| 294 |
+
});
|
| 295 |
+
|
| 296 |
+
it('preserves object page SSE error messages', async () => {
|
| 297 |
+
await withServer(
|
| 298 |
+
(request, response) => {
|
| 299 |
+
if (request.url === '/api/agent/capabilities') {
|
| 300 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 301 |
+
response.end(
|
| 302 |
+
JSON.stringify({
|
| 303 |
+
agent_streaming: {
|
| 304 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 305 |
+
},
|
| 306 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 307 |
+
})
|
| 308 |
+
);
|
| 309 |
+
return;
|
| 310 |
+
}
|
| 311 |
+
if (request.url === '/api/images') {
|
| 312 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 313 |
+
response.end(
|
| 314 |
+
[
|
| 315 |
+
'data: {"type":"error","error":{"message":"bad request from page","code":"validation_error","status":400}}',
|
| 316 |
+
'',
|
| 317 |
+
''
|
| 318 |
+
].join('\n')
|
| 319 |
+
);
|
| 320 |
+
return;
|
| 321 |
+
}
|
| 322 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 323 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 324 |
+
},
|
| 325 |
+
async (baseUrl) => {
|
| 326 |
+
const result = await runSkillScriptAsync(
|
| 327 |
+
'generate-image.mjs',
|
| 328 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 329 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 330 |
+
);
|
| 331 |
+
|
| 332 |
+
assert.equal(result.status, 1);
|
| 333 |
+
assert.equal(result.stdout.trim(), '');
|
| 334 |
+
const body = JSON.parse(result.stderr);
|
| 335 |
+
assert.equal(body.error.code, 'page_sse_failed');
|
| 336 |
+
assert.equal(body.billable, true);
|
| 337 |
+
assert.equal(body.error.status, 400);
|
| 338 |
+
assert.match(body.error.message, /bad request from page/);
|
| 339 |
+
assert.doesNotMatch(body.error.message, /\[object Object\]/);
|
| 340 |
+
}
|
| 341 |
+
);
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
it('fails large generate auto routing when page SSE capability is unavailable', async () => {
|
| 345 |
+
const requests = [];
|
| 346 |
+
await withServer(
|
| 347 |
+
(request, response) => {
|
| 348 |
+
requests.push({ method: request.method, url: request.url });
|
| 349 |
+
if (request.url === '/api/agent/capabilities') {
|
| 350 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 351 |
+
response.end(JSON.stringify({ agent_streaming: {}, agent_jobs: { supported: true, mode: 'job_polling' } }));
|
| 352 |
+
return;
|
| 353 |
+
}
|
| 354 |
+
if (request.url === '/api/agent/images/generate') {
|
| 355 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 356 |
+
response.end(JSON.stringify({ error: 'unexpected fallback' }));
|
| 357 |
+
return;
|
| 358 |
+
}
|
| 359 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 360 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 361 |
+
},
|
| 362 |
+
async (baseUrl) => {
|
| 363 |
+
const result = await runSkillScriptAsync(
|
| 364 |
+
'generate-image.mjs',
|
| 365 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 366 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 367 |
+
);
|
| 368 |
+
|
| 369 |
+
assert.equal(result.status, 1);
|
| 370 |
+
assert.equal(result.stdout.trim(), '');
|
| 371 |
+
const body = JSON.parse(result.stderr);
|
| 372 |
+
assert.equal(body.error.code, 'page_sse_unavailable');
|
| 373 |
+
assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis');
|
| 374 |
+
assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']);
|
| 375 |
+
}
|
| 376 |
+
);
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
it('fails page SSE output when the stream ends before the done event', async () => {
|
| 380 |
+
await withServer(
|
| 381 |
+
(request, response) => {
|
| 382 |
+
if (request.url === '/api/agent/capabilities') {
|
| 383 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 384 |
+
response.end(
|
| 385 |
+
JSON.stringify({
|
| 386 |
+
agent_streaming: {
|
| 387 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 388 |
+
},
|
| 389 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 390 |
+
})
|
| 391 |
+
);
|
| 392 |
+
return;
|
| 393 |
+
}
|
| 394 |
+
if (request.url === '/api/images') {
|
| 395 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 396 |
+
response.end(
|
| 397 |
+
[
|
| 398 |
+
'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","path":"/api/image/image.png","output_format":"png"}',
|
| 399 |
+
'',
|
| 400 |
+
''
|
| 401 |
+
].join('\n')
|
| 402 |
+
);
|
| 403 |
+
return;
|
| 404 |
+
}
|
| 405 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 406 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 407 |
+
},
|
| 408 |
+
async (baseUrl) => {
|
| 409 |
+
const result = await runSkillScriptAsync(
|
| 410 |
+
'generate-image.mjs',
|
| 411 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 412 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 413 |
+
);
|
| 414 |
+
|
| 415 |
+
assert.equal(result.status, 1);
|
| 416 |
+
assert.equal(result.stdout.trim(), '');
|
| 417 |
+
const body = JSON.parse(result.stderr);
|
| 418 |
+
assert.equal(body.error.code, 'page_sse_failed');
|
| 419 |
+
assert.match(body.error.message, /缺少最终 done 事件/);
|
| 420 |
+
}
|
| 421 |
+
);
|
| 422 |
+
});
|
| 423 |
+
|
| 424 |
+
it('requires page access hash before calling page SSE when capabilities declare page auth', async () => {
|
| 425 |
+
const requests = [];
|
| 426 |
+
await withServer(
|
| 427 |
+
(request, response) => {
|
| 428 |
+
requests.push({ method: request.method, url: request.url });
|
| 429 |
+
if (request.url === '/api/agent/capabilities') {
|
| 430 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 431 |
+
response.end(
|
| 432 |
+
JSON.stringify({
|
| 433 |
+
agent_streaming: {
|
| 434 |
+
page_sse: {
|
| 435 |
+
supported: true,
|
| 436 |
+
endpoint: '/api/images',
|
| 437 |
+
auth: { required: true, schemes: ['form-password-hash'], form_field: 'passwordHash' }
|
| 438 |
+
}
|
| 439 |
+
},
|
| 440 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 441 |
+
})
|
| 442 |
+
);
|
| 443 |
+
return;
|
| 444 |
+
}
|
| 445 |
+
if (request.url === '/api/images') {
|
| 446 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 447 |
+
response.end(JSON.stringify({ error: 'unexpected page call' }));
|
| 448 |
+
return;
|
| 449 |
+
}
|
| 450 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 451 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 452 |
+
},
|
| 453 |
+
async (baseUrl) => {
|
| 454 |
+
const result = await runSkillScriptAsync(
|
| 455 |
+
'generate-image.mjs',
|
| 456 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 457 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 458 |
+
);
|
| 459 |
+
|
| 460 |
+
assert.equal(result.status, 1);
|
| 461 |
+
assert.equal(result.stdout.trim(), '');
|
| 462 |
+
const body = JSON.parse(result.stderr);
|
| 463 |
+
assert.equal(body.error.code, 'page_sse_auth_required');
|
| 464 |
+
assert.match(body.error.message, /GPT_IMAGE_APP_PASSWORD_HASH/);
|
| 465 |
+
assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']);
|
| 466 |
+
}
|
| 467 |
+
);
|
| 468 |
+
});
|
| 469 |
+
|
| 470 |
+
it('rejects overlong page SSE client request ids before sending the stream request', async () => {
|
| 471 |
+
const requests = [];
|
| 472 |
+
await withServer(
|
| 473 |
+
(request, response) => {
|
| 474 |
+
requests.push({ method: request.method, url: request.url });
|
| 475 |
+
if (request.url === '/api/agent/capabilities') {
|
| 476 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 477 |
+
response.end(
|
| 478 |
+
JSON.stringify({
|
| 479 |
+
agent_streaming: {
|
| 480 |
+
page_sse: {
|
| 481 |
+
supported: true,
|
| 482 |
+
endpoint: '/api/images',
|
| 483 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 484 |
+
}
|
| 485 |
+
},
|
| 486 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 487 |
+
})
|
| 488 |
+
);
|
| 489 |
+
return;
|
| 490 |
+
}
|
| 491 |
+
if (request.url === '/api/images') {
|
| 492 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 493 |
+
response.end(JSON.stringify({ error: 'unexpected page call' }));
|
| 494 |
+
return;
|
| 495 |
+
}
|
| 496 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 497 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 498 |
+
},
|
| 499 |
+
async (baseUrl) => {
|
| 500 |
+
const result = await runSkillScriptAsync(
|
| 501 |
+
'generate-image.mjs',
|
| 502 |
+
[
|
| 503 |
+
'--allow-billable',
|
| 504 |
+
'--page-sse',
|
| 505 |
+
'--size',
|
| 506 |
+
'3072x2048',
|
| 507 |
+
'--quality',
|
| 508 |
+
'high',
|
| 509 |
+
'prompt'
|
| 510 |
+
],
|
| 511 |
+
{
|
| 512 |
+
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 513 |
+
GPT_IMAGE_AGENT_IDEMPOTENCY_KEY: 'x'.repeat(129)
|
| 514 |
+
}
|
| 515 |
+
);
|
| 516 |
+
|
| 517 |
+
assert.equal(result.status, 1);
|
| 518 |
+
assert.equal(result.stdout.trim(), '');
|
| 519 |
+
const body = JSON.parse(result.stderr);
|
| 520 |
+
assert.equal(body.error.code, 'page_sse_client_request_id_too_long');
|
| 521 |
+
assert.match(body.error.message, /不能超过 128 个字符/);
|
| 522 |
+
assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']);
|
| 523 |
+
}
|
| 524 |
+
);
|
| 525 |
+
});
|
| 526 |
+
|
| 527 |
+
it('strips page SSE base64 from default path-mode output', async () => {
|
| 528 |
+
await withServer(
|
| 529 |
+
(request, response) => {
|
| 530 |
+
if (request.url === '/api/agent/capabilities') {
|
| 531 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 532 |
+
response.end(
|
| 533 |
+
JSON.stringify({
|
| 534 |
+
agent_streaming: {
|
| 535 |
+
page_sse: {
|
| 536 |
+
supported: true,
|
| 537 |
+
endpoint: '/api/images',
|
| 538 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 539 |
+
}
|
| 540 |
+
},
|
| 541 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 542 |
+
})
|
| 543 |
+
);
|
| 544 |
+
return;
|
| 545 |
+
}
|
| 546 |
+
if (request.url === '/api/images') {
|
| 547 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 548 |
+
response.end(
|
| 549 |
+
[
|
| 550 |
+
'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","path":"/api/image/image.png","output_format":"png"}',
|
| 551 |
+
'',
|
| 552 |
+
'data: {"type":"done","client_request_id":"page-request-1","images":[{"filename":"image.png"}]}',
|
| 553 |
+
'',
|
| 554 |
+
''
|
| 555 |
+
].join('\n')
|
| 556 |
+
);
|
| 557 |
+
return;
|
| 558 |
+
}
|
| 559 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 560 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 561 |
+
},
|
| 562 |
+
async (baseUrl) => {
|
| 563 |
+
const result = await runSkillScriptAsync(
|
| 564 |
+
'generate-image.mjs',
|
| 565 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 566 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 567 |
+
);
|
| 568 |
+
|
| 569 |
+
assert.equal(result.status, 0);
|
| 570 |
+
assert.equal(result.stderr.trim(), '');
|
| 571 |
+
const body = JSON.parse(result.stdout);
|
| 572 |
+
assert.equal(body.images[0].filename, 'image.png');
|
| 573 |
+
assert.equal('b64_json' in body.images[0], false);
|
| 574 |
+
assert.equal(body.images[0].path, '/api/image/image.png');
|
| 575 |
+
assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/image.png`);
|
| 576 |
+
assert.equal(body.images[0].output_format, 'png');
|
| 577 |
+
assert.equal(body.images[0].clientRequestId, 'page-request-1');
|
| 578 |
+
}
|
| 579 |
+
);
|
| 580 |
+
});
|
| 581 |
+
|
| 582 |
+
it('passes response mode through to page SSE form-data requests', async () => {
|
| 583 |
+
let pageRequestBody = '';
|
| 584 |
+
await withServer(
|
| 585 |
+
async (request, response) => {
|
| 586 |
+
if (request.url === '/api/agent/capabilities') {
|
| 587 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 588 |
+
response.end(
|
| 589 |
+
JSON.stringify({
|
| 590 |
+
agent_streaming: {
|
| 591 |
+
page_sse: {
|
| 592 |
+
supported: true,
|
| 593 |
+
endpoint: '/api/images',
|
| 594 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 595 |
+
}
|
| 596 |
+
},
|
| 597 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 598 |
+
})
|
| 599 |
+
);
|
| 600 |
+
return;
|
| 601 |
+
}
|
| 602 |
+
if (request.url === '/api/images') {
|
| 603 |
+
pageRequestBody = await readRequestText(request);
|
| 604 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 605 |
+
response.end(
|
| 606 |
+
[
|
| 607 |
+
'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}',
|
| 608 |
+
'',
|
| 609 |
+
'data: {"type":"done","client_request_id":"page-request-response-mode"}',
|
| 610 |
+
'',
|
| 611 |
+
''
|
| 612 |
+
].join('\n')
|
| 613 |
+
);
|
| 614 |
+
return;
|
| 615 |
+
}
|
| 616 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 617 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 618 |
+
},
|
| 619 |
+
async (baseUrl) => {
|
| 620 |
+
const result = await runSkillScriptAsync(
|
| 621 |
+
'generate-image.mjs',
|
| 622 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', '--response-mode', 'base64', 'prompt'],
|
| 623 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 624 |
+
);
|
| 625 |
+
|
| 626 |
+
assert.equal(result.status, 0);
|
| 627 |
+
assert.equal(result.stderr.trim(), '');
|
| 628 |
+
assert.match(pageRequestBody, /name="response_mode"\r?\n\r?\nbase64/);
|
| 629 |
+
}
|
| 630 |
+
);
|
| 631 |
+
});
|
| 632 |
+
|
| 633 |
+
it('omits page SSE streaming strategy unless explicitly requested', async () => {
|
| 634 |
+
let pageRequestBody = '';
|
| 635 |
+
await withServer(
|
| 636 |
+
async (request, response) => {
|
| 637 |
+
if (request.url === '/api/agent/capabilities') {
|
| 638 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 639 |
+
response.end(
|
| 640 |
+
JSON.stringify({
|
| 641 |
+
agent_streaming: {
|
| 642 |
+
page_sse: {
|
| 643 |
+
supported: true,
|
| 644 |
+
endpoint: '/api/images',
|
| 645 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 646 |
+
}
|
| 647 |
+
},
|
| 648 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 649 |
+
})
|
| 650 |
+
);
|
| 651 |
+
return;
|
| 652 |
+
}
|
| 653 |
+
if (request.url === '/api/images') {
|
| 654 |
+
pageRequestBody = await readRequestText(request);
|
| 655 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 656 |
+
response.end(
|
| 657 |
+
[
|
| 658 |
+
'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}',
|
| 659 |
+
'',
|
| 660 |
+
'data: {"type":"done","client_request_id":"page-request-default-streaming"}',
|
| 661 |
+
'',
|
| 662 |
+
''
|
| 663 |
+
].join('\n')
|
| 664 |
+
);
|
| 665 |
+
return;
|
| 666 |
+
}
|
| 667 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 668 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 669 |
+
},
|
| 670 |
+
async (baseUrl) => {
|
| 671 |
+
const result = await runSkillScriptAsync(
|
| 672 |
+
'generate-image.mjs',
|
| 673 |
+
['--allow-billable', '--page-sse', '--size', '1024x1024', '--quality', 'high', 'prompt'],
|
| 674 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 675 |
+
);
|
| 676 |
+
|
| 677 |
+
assert.equal(result.status, 0);
|
| 678 |
+
assert.equal(result.stderr.trim(), '');
|
| 679 |
+
assert.match(pageRequestBody, /name="stream"\r?\n\r?\ntrue/);
|
| 680 |
+
assert.doesNotMatch(pageRequestBody, /name="image_streaming_strategy"/);
|
| 681 |
+
}
|
| 682 |
+
);
|
| 683 |
+
});
|
| 684 |
+
|
| 685 |
+
it('enforces timeout while waiting for page SSE body events', async () => {
|
| 686 |
+
await withServer(
|
| 687 |
+
(request, response) => {
|
| 688 |
+
if (request.url === '/api/agent/capabilities') {
|
| 689 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 690 |
+
response.end(
|
| 691 |
+
JSON.stringify({
|
| 692 |
+
agent_streaming: {
|
| 693 |
+
page_sse: {
|
| 694 |
+
supported: true,
|
| 695 |
+
endpoint: '/api/images',
|
| 696 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 697 |
+
}
|
| 698 |
+
},
|
| 699 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 700 |
+
})
|
| 701 |
+
);
|
| 702 |
+
return;
|
| 703 |
+
}
|
| 704 |
+
if (request.url === '/api/images') {
|
| 705 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 706 |
+
response.write(': keepalive\n\n');
|
| 707 |
+
return;
|
| 708 |
+
}
|
| 709 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 710 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 711 |
+
},
|
| 712 |
+
async (baseUrl) => {
|
| 713 |
+
const result = await runSkillScriptAsync(
|
| 714 |
+
'generate-image.mjs',
|
| 715 |
+
['--allow-billable', '--page-sse', '--timeout-ms', '250', '--size', '1024x1024', '--quality', 'high', 'prompt'],
|
| 716 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl },
|
| 717 |
+
{ timeoutMs: 1_200 }
|
| 718 |
+
);
|
| 719 |
+
|
| 720 |
+
assert.equal(result.timedOut, false);
|
| 721 |
+
assert.equal(result.status, 1);
|
| 722 |
+
assert.equal(result.stdout.trim(), '');
|
| 723 |
+
const body = JSON.parse(result.stderr);
|
| 724 |
+
assert.equal(body.error.code, 'page_sse_failed');
|
| 725 |
+
assert.match(body.error.message, /\/api\/images/);
|
| 726 |
+
}
|
| 727 |
+
);
|
| 728 |
+
});
|
| 729 |
+
|
| 730 |
+
it('passes page access hash through to page SSE form-data requests when configured', async () => {
|
| 731 |
+
let pageRequestBody = '';
|
| 732 |
+
await withServer(
|
| 733 |
+
async (request, response) => {
|
| 734 |
+
if (request.url === '/api/agent/capabilities') {
|
| 735 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 736 |
+
response.end(
|
| 737 |
+
JSON.stringify({
|
| 738 |
+
agent_streaming: {
|
| 739 |
+
page_sse: {
|
| 740 |
+
supported: true,
|
| 741 |
+
endpoint: '/api/images',
|
| 742 |
+
auth: { required: true, schemes: ['form-password-hash'], form_field: 'passwordHash' }
|
| 743 |
+
}
|
| 744 |
+
},
|
| 745 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 746 |
+
})
|
| 747 |
+
);
|
| 748 |
+
return;
|
| 749 |
+
}
|
| 750 |
+
if (request.url === '/api/images') {
|
| 751 |
+
pageRequestBody = await readRequestText(request);
|
| 752 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 753 |
+
response.end(
|
| 754 |
+
[
|
| 755 |
+
'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}',
|
| 756 |
+
'',
|
| 757 |
+
'data: {"type":"done","client_request_id":"page-request-password"}',
|
| 758 |
+
'',
|
| 759 |
+
''
|
| 760 |
+
].join('\n')
|
| 761 |
+
);
|
| 762 |
+
return;
|
| 763 |
+
}
|
| 764 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 765 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 766 |
+
},
|
| 767 |
+
async (baseUrl) => {
|
| 768 |
+
const result = await runSkillScriptAsync(
|
| 769 |
+
'generate-image.mjs',
|
| 770 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 771 |
+
{
|
| 772 |
+
GPT_IMAGE_PLAYGROUND_URL: baseUrl,
|
| 773 |
+
GPT_IMAGE_APP_PASSWORD_HASH: 'hash-for-page-sse'
|
| 774 |
+
}
|
| 775 |
+
);
|
| 776 |
+
|
| 777 |
+
assert.equal(result.status, 0);
|
| 778 |
+
assert.equal(result.stderr.trim(), '');
|
| 779 |
+
assert.match(pageRequestBody, /name="passwordHash"\r?\n\r?\nhash-for-page-sse/);
|
| 780 |
+
}
|
| 781 |
+
);
|
| 782 |
+
});
|
| 783 |
+
|
| 784 |
+
it('keeps page SSE base64 in default path-mode output when no path is returned', async () => {
|
| 785 |
+
await withServer(
|
| 786 |
+
(request, response) => {
|
| 787 |
+
if (request.url === '/api/agent/capabilities') {
|
| 788 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 789 |
+
response.end(
|
| 790 |
+
JSON.stringify({
|
| 791 |
+
agent_streaming: {
|
| 792 |
+
page_sse: {
|
| 793 |
+
supported: true,
|
| 794 |
+
endpoint: '/api/images',
|
| 795 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 796 |
+
}
|
| 797 |
+
},
|
| 798 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 799 |
+
})
|
| 800 |
+
);
|
| 801 |
+
return;
|
| 802 |
+
}
|
| 803 |
+
if (request.url === '/api/images') {
|
| 804 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 805 |
+
response.end(
|
| 806 |
+
[
|
| 807 |
+
'data: {"type":"completed","filename":"image.png","b64_json":"indexeddb-base64","output_format":"png"}',
|
| 808 |
+
'',
|
| 809 |
+
'data: {"type":"done","client_request_id":"page-request-indexeddb"}',
|
| 810 |
+
'',
|
| 811 |
+
''
|
| 812 |
+
].join('\n')
|
| 813 |
+
);
|
| 814 |
+
return;
|
| 815 |
+
}
|
| 816 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 817 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 818 |
+
},
|
| 819 |
+
async (baseUrl) => {
|
| 820 |
+
const result = await runSkillScriptAsync(
|
| 821 |
+
'generate-image.mjs',
|
| 822 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 823 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 824 |
+
);
|
| 825 |
+
|
| 826 |
+
assert.equal(result.status, 0);
|
| 827 |
+
assert.equal(result.stderr.trim(), '');
|
| 828 |
+
const body = JSON.parse(result.stdout);
|
| 829 |
+
assert.equal(body.images[0].filename, 'image.png');
|
| 830 |
+
assert.equal(body.images[0].b64_json, 'indexeddb-base64');
|
| 831 |
+
assert.equal('absolute_path' in body.images[0], false);
|
| 832 |
+
assert.equal(body.images[0].clientRequestId, 'page-request-indexeddb');
|
| 833 |
+
}
|
| 834 |
+
);
|
| 835 |
+
});
|
| 836 |
+
|
| 837 |
+
it('preserves completed page SSE images when the done event lists fewer images', async () => {
|
| 838 |
+
await withServer(
|
| 839 |
+
(request, response) => {
|
| 840 |
+
if (request.url === '/api/agent/capabilities') {
|
| 841 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 842 |
+
response.end(
|
| 843 |
+
JSON.stringify({
|
| 844 |
+
agent_streaming: {
|
| 845 |
+
page_sse: {
|
| 846 |
+
supported: true,
|
| 847 |
+
endpoint: '/api/images',
|
| 848 |
+
auth: { required: false, schemes: [], form_field: 'passwordHash' }
|
| 849 |
+
}
|
| 850 |
+
},
|
| 851 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 852 |
+
})
|
| 853 |
+
);
|
| 854 |
+
return;
|
| 855 |
+
}
|
| 856 |
+
if (request.url === '/api/images') {
|
| 857 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 858 |
+
response.end(
|
| 859 |
+
[
|
| 860 |
+
'data: {"type":"completed","filename":"image-a.png","b64_json":"base64-a","path":"/api/image/image-a.png","output_format":"png"}',
|
| 861 |
+
'',
|
| 862 |
+
'data: {"type":"completed","filename":"image-b.png","b64_json":"base64-b","path":"/api/image/image-b.png","output_format":"png"}',
|
| 863 |
+
'',
|
| 864 |
+
'data: {"type":"done","client_request_id":"page-request-2","images":[{"filename":"image-a.png"}]}',
|
| 865 |
+
'',
|
| 866 |
+
''
|
| 867 |
+
].join('\n')
|
| 868 |
+
);
|
| 869 |
+
return;
|
| 870 |
+
}
|
| 871 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 872 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 873 |
+
},
|
| 874 |
+
async (baseUrl) => {
|
| 875 |
+
const result = await runSkillScriptAsync(
|
| 876 |
+
'generate-image.mjs',
|
| 877 |
+
['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 878 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 879 |
+
);
|
| 880 |
+
|
| 881 |
+
assert.equal(result.status, 0);
|
| 882 |
+
assert.equal(result.stderr.trim(), '');
|
| 883 |
+
const body = JSON.parse(result.stdout);
|
| 884 |
+
assert.equal(body.images.length, 2);
|
| 885 |
+
assert.equal(body.images[0].filename, 'image-a.png');
|
| 886 |
+
assert.equal(body.images[1].filename, 'image-b.png');
|
| 887 |
+
assert.equal(body.images[1].path, '/api/image/image-b.png');
|
| 888 |
+
assert.equal(body.images[1].absolute_path, `${baseUrl}/api/image/image-b.png`);
|
| 889 |
+
assert.equal(body.images[1].clientRequestId, 'page-request-2');
|
| 890 |
+
}
|
| 891 |
+
);
|
| 892 |
+
});
|
| 893 |
+
|
| 894 |
+
it('uses Agent JSON for billable large generate requests when --agent is explicit', async () => {
|
| 895 |
+
const requests = [];
|
| 896 |
+
await withServer(
|
| 897 |
+
(request, response) => {
|
| 898 |
+
requests.push({ method: request.method, url: request.url });
|
| 899 |
+
if (request.url === '/api/agent/capabilities') {
|
| 900 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 901 |
+
response.end(
|
| 902 |
+
JSON.stringify({
|
| 903 |
+
agent_streaming: {
|
| 904 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 905 |
+
},
|
| 906 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 907 |
+
})
|
| 908 |
+
);
|
| 909 |
+
return;
|
| 910 |
+
}
|
| 911 |
+
if (request.url === '/api/agent/images/generate') {
|
| 912 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 913 |
+
response.end(
|
| 914 |
+
JSON.stringify({
|
| 915 |
+
images: [
|
| 916 |
+
{
|
| 917 |
+
filename: 'agent.png',
|
| 918 |
+
content_url: '/api/agent/artifacts/artifact-1/content',
|
| 919 |
+
metadata_url: '/api/agent/artifacts/artifact-1'
|
| 920 |
+
}
|
| 921 |
+
]
|
| 922 |
+
})
|
| 923 |
+
);
|
| 924 |
+
return;
|
| 925 |
+
}
|
| 926 |
+
if (request.url === '/api/images') {
|
| 927 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 928 |
+
response.end(JSON.stringify({ error: 'unexpected page SSE call' }));
|
| 929 |
+
return;
|
| 930 |
+
}
|
| 931 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 932 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 933 |
+
},
|
| 934 |
+
async (baseUrl) => {
|
| 935 |
+
const result = await runSkillScriptAsync(
|
| 936 |
+
'generate-image.mjs',
|
| 937 |
+
['--allow-billable', '--agent', '--size', '3072x2048', '--quality', 'high', 'prompt'],
|
| 938 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 939 |
+
);
|
| 940 |
+
|
| 941 |
+
assert.equal(result.status, 0);
|
| 942 |
+
assert.equal(result.stderr.trim(), '');
|
| 943 |
+
const body = JSON.parse(result.stdout);
|
| 944 |
+
assert.equal(body.images[0].filename, 'agent.png');
|
| 945 |
+
assert.equal(body.images[0].absolute_content_url, `${baseUrl}/api/agent/artifacts/artifact-1/content`);
|
| 946 |
+
assert.deepEqual(body.routing, { transport: 'agent_json', endpoint: '/api/agent/images/generate' });
|
| 947 |
+
assert.deepEqual(
|
| 948 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 949 |
+
['GET /api/agent/capabilities', 'POST /api/agent/images/generate']
|
| 950 |
+
);
|
| 951 |
+
}
|
| 952 |
+
);
|
| 953 |
+
});
|
| 954 |
+
|
| 955 |
+
it('uses page SSE for billable small generate requests when --page-sse is explicit', async () => {
|
| 956 |
+
const requests = [];
|
| 957 |
+
await withServer(
|
| 958 |
+
(request, response) => {
|
| 959 |
+
requests.push({ method: request.method, url: request.url });
|
| 960 |
+
if (request.url === '/api/agent/capabilities') {
|
| 961 |
+
response.writeHead(200, { 'content-type': 'application/json' });
|
| 962 |
+
response.end(
|
| 963 |
+
JSON.stringify({
|
| 964 |
+
agent_streaming: {
|
| 965 |
+
page_sse: { supported: true, endpoint: '/api/images' }
|
| 966 |
+
},
|
| 967 |
+
agent_jobs: { supported: true, mode: 'job_polling' }
|
| 968 |
+
})
|
| 969 |
+
);
|
| 970 |
+
return;
|
| 971 |
+
}
|
| 972 |
+
if (request.url === '/api/images') {
|
| 973 |
+
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
| 974 |
+
response.end(
|
| 975 |
+
[
|
| 976 |
+
'data: {"type":"completed","filename":"small-page.png","path":"/api/image/small-page.png","output_format":"png"}',
|
| 977 |
+
'',
|
| 978 |
+
'data: {"type":"done","client_request_id":"small-page-request","images":[{"filename":"small-page.png"}]}',
|
| 979 |
+
'',
|
| 980 |
+
''
|
| 981 |
+
].join('\n')
|
| 982 |
+
);
|
| 983 |
+
return;
|
| 984 |
+
}
|
| 985 |
+
if (request.url === '/api/agent/images/generate') {
|
| 986 |
+
response.writeHead(500, { 'content-type': 'application/json' });
|
| 987 |
+
response.end(JSON.stringify({ error: 'unexpected Agent JSON call' }));
|
| 988 |
+
return;
|
| 989 |
+
}
|
| 990 |
+
response.writeHead(404, { 'content-type': 'application/json' });
|
| 991 |
+
response.end(JSON.stringify({ error: 'missing' }));
|
| 992 |
+
},
|
| 993 |
+
async (baseUrl) => {
|
| 994 |
+
const result = await runSkillScriptAsync(
|
| 995 |
+
'generate-image.mjs',
|
| 996 |
+
['--allow-billable', '--page-sse', '--size', '1024x1024', '--quality', 'high', 'prompt'],
|
| 997 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 998 |
+
);
|
| 999 |
+
|
| 1000 |
+
assert.equal(result.status, 0);
|
| 1001 |
+
assert.equal(result.stderr.trim(), '');
|
| 1002 |
+
const body = JSON.parse(result.stdout);
|
| 1003 |
+
assert.equal(body.images[0].filename, 'small-page.png');
|
| 1004 |
+
assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/small-page.png`);
|
| 1005 |
+
assert.deepEqual(body.routing, { transport: 'page_sse', endpoint: '/api/images' });
|
| 1006 |
+
assert.deepEqual(
|
| 1007 |
+
requests.map((item) => `${item.method} ${item.url}`),
|
| 1008 |
+
['GET /api/agent/capabilities', 'POST /api/images']
|
| 1009 |
+
);
|
| 1010 |
+
}
|
| 1011 |
+
);
|
| 1012 |
+
});
|
| 1013 |
+
|
| 1014 |
+
it('prints page SSE guidance for high-resolution edit dry-runs', () => {
|
| 1015 |
+
const result = runSkillScript('edit-image.mjs', [
|
| 1016 |
+
'--size',
|
| 1017 |
+
'3072x2048',
|
| 1018 |
+
'--quality',
|
| 1019 |
+
'high',
|
| 1020 |
+
'/tmp/source.png',
|
| 1021 |
+
'prompt'
|
| 1022 |
+
]);
|
| 1023 |
+
|
| 1024 |
+
assert.equal(result.status, 0);
|
| 1025 |
+
const body = JSON.parse(result.stdout);
|
| 1026 |
+
assert.equal(body.routing_guidance.recommended_endpoint, '/api/images');
|
| 1027 |
+
assert.equal(body.routing_guidance.transport, 'page_sse');
|
| 1028 |
+
assert.equal(body.routing_guidance.strength, 'must_use');
|
| 1029 |
+
assert.equal(result.stderr.trim(), '');
|
| 1030 |
+
});
|
| 1031 |
+
|
| 1032 |
+
it('blocks billable high-resolution Agent edit requests before reading image files', () => {
|
| 1033 |
+
const result = runSkillScript('edit-image.mjs', [
|
| 1034 |
+
'--allow-billable',
|
| 1035 |
+
'--size',
|
| 1036 |
+
'3072x2048',
|
| 1037 |
+
'/tmp/missing-source.png',
|
| 1038 |
+
'prompt'
|
| 1039 |
+
]);
|
| 1040 |
+
|
| 1041 |
+
assert.equal(result.status, 2);
|
| 1042 |
+
const body = JSON.parse(result.stderr);
|
| 1043 |
+
assert.equal(body.billable, false);
|
| 1044 |
+
assert.equal(body.routing_guidance.recommended_endpoint, '/api/images');
|
| 1045 |
+
assert.equal(body.routing_guidance.strength, 'must_use');
|
| 1046 |
+
assert.equal(result.stdout.trim(), '');
|
| 1047 |
+
});
|
| 1048 |
+
|
| 1049 |
+
it('rejects invalid generate upstream streaming options before dry-run output', () => {
|
| 1050 |
+
const invalidPartialImages = runSkillScript('generate-image.mjs', ['--partial-images', '4', 'prompt']);
|
| 1051 |
+
assert.equal(invalidPartialImages.status, 2);
|
| 1052 |
+
assert.match(invalidPartialImages.stderr, /--partial-images 必须是 1 到 3 的整数/);
|
| 1053 |
+
assert.equal(invalidPartialImages.stdout.trim(), '');
|
| 1054 |
+
|
| 1055 |
+
const invalidBackend = runSkillScript('generate-image.mjs', ['--image-backend', 'unknown-backend', 'prompt']);
|
| 1056 |
+
assert.equal(invalidBackend.status, 2);
|
| 1057 |
+
assert.match(invalidBackend.stderr, /--image-backend 必须是/);
|
| 1058 |
+
assert.equal(invalidBackend.stdout.trim(), '');
|
| 1059 |
+
|
| 1060 |
+
const invalidStrategy = runSkillScript('generate-image.mjs', [
|
| 1061 |
+
'--streaming-strategy',
|
| 1062 |
+
'unknown-strategy',
|
| 1063 |
+
'prompt'
|
| 1064 |
+
]);
|
| 1065 |
+
assert.equal(invalidStrategy.status, 2);
|
| 1066 |
+
assert.match(invalidStrategy.stderr, /--streaming-strategy 必须是/);
|
| 1067 |
+
assert.equal(invalidStrategy.stdout.trim(), '');
|
| 1068 |
+
|
| 1069 |
+
const invalidResponseMode = runSkillScript('generate-image.mjs', ['--response-mode', 'url', 'prompt']);
|
| 1070 |
+
assert.equal(invalidResponseMode.status, 2);
|
| 1071 |
+
assert.match(invalidResponseMode.stderr, /--response-mode 必须是 path、base64 或 both/);
|
| 1072 |
+
assert.equal(invalidResponseMode.stdout.trim(), '');
|
| 1073 |
+
|
| 1074 |
+
const disabledAutoPageSse = runSkillScript('generate-image.mjs', [
|
| 1075 |
+
'--size',
|
| 1076 |
+
'3072x2048',
|
| 1077 |
+
'--streaming-strategy',
|
| 1078 |
+
'off',
|
| 1079 |
+
'prompt'
|
| 1080 |
+
]);
|
| 1081 |
+
assert.equal(disabledAutoPageSse.status, 0);
|
| 1082 |
+
const disabledAutoPageSseBody = JSON.parse(disabledAutoPageSse.stdout);
|
| 1083 |
+
assert.equal(disabledAutoPageSseBody.endpoint, 'http://localhost:4783/api/agent/images/generate');
|
| 1084 |
+
assert.equal(disabledAutoPageSseBody.routing_guidance.recommended_endpoint, '/api/agent/images/generate');
|
| 1085 |
+
assert.equal(disabledAutoPageSse.stderr.trim(), '');
|
| 1086 |
+
|
| 1087 |
+
const disabledPageSse = runSkillScript('generate-image.mjs', [
|
| 1088 |
+
'--page-sse',
|
| 1089 |
+
'--streaming-strategy',
|
| 1090 |
+
'off',
|
| 1091 |
+
'prompt'
|
| 1092 |
+
]);
|
| 1093 |
+
assert.equal(disabledPageSse.status, 2);
|
| 1094 |
+
assert.match(disabledPageSse.stderr, /streaming_strategy=off/);
|
| 1095 |
+
assert.doesNotMatch(disabledPageSse.stderr, /ModuleJob|at buildGenerateRoutingGuidance/);
|
| 1096 |
+
assert.equal(disabledPageSse.stdout.trim(), '');
|
| 1097 |
+
});
|
| 1098 |
+
|
| 1099 |
it('shows edit help without validating unrelated env values', () => {
|
| 1100 |
const result = runSkillScript('edit-image.mjs', ['--help'], {
|
| 1101 |
GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc'
|
|
|
|
| 1158 |
}
|
| 1159 |
);
|
| 1160 |
});
|
| 1161 |
+
|
| 1162 |
+
it('does not duplicate network failure prefixes in generate capability errors', async () => {
|
| 1163 |
+
await withServer(
|
| 1164 |
+
(request, response) => {
|
| 1165 |
+
if (request.url === '/api/agent/capabilities') {
|
| 1166 |
+
return;
|
| 1167 |
+
}
|
| 1168 |
+
response.writeHead(404, { 'content-type': 'text/plain' });
|
| 1169 |
+
response.end('missing');
|
| 1170 |
+
},
|
| 1171 |
+
async (baseUrl) => {
|
| 1172 |
+
const result = await runSkillScriptAsync(
|
| 1173 |
+
'generate-image.mjs',
|
| 1174 |
+
['--allow-billable', '--timeout-ms', '50', 'prompt'],
|
| 1175 |
+
{ GPT_IMAGE_PLAYGROUND_URL: baseUrl }
|
| 1176 |
+
);
|
| 1177 |
+
|
| 1178 |
+
assert.equal(result.status, 1);
|
| 1179 |
+
assert.equal(result.stdout.trim(), '');
|
| 1180 |
+
const prefixes = result.stderr.match(/请求失败:/g) || [];
|
| 1181 |
+
assert.equal(prefixes.length, 1);
|
| 1182 |
+
assert.match(result.stderr, /\/api\/agent\/capabilities/);
|
| 1183 |
+
}
|
| 1184 |
+
);
|
| 1185 |
+
});
|
| 1186 |
});
|
| 1187 |
|
| 1188 |
function runSkillScript(filename, args, env = {}) {
|
|
|
|
| 1193 |
});
|
| 1194 |
}
|
| 1195 |
|
| 1196 |
+
function runSkillScriptAsync(filename, args, env = {}, options = {}) {
|
| 1197 |
return new Promise((resolve) => {
|
| 1198 |
const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], {
|
| 1199 |
cwd: repoRoot,
|
|
|
|
| 1202 |
});
|
| 1203 |
let stdout = '';
|
| 1204 |
let stderr = '';
|
| 1205 |
+
let timedOut = false;
|
| 1206 |
+
const timeout = options.timeoutMs
|
| 1207 |
+
? setTimeout(() => {
|
| 1208 |
+
timedOut = true;
|
| 1209 |
+
child.kill('SIGTERM');
|
| 1210 |
+
}, options.timeoutMs)
|
| 1211 |
+
: undefined;
|
| 1212 |
child.stdout.setEncoding('utf8');
|
| 1213 |
child.stderr.setEncoding('utf8');
|
| 1214 |
child.stdout.on('data', (chunk) => {
|
|
|
|
| 1217 |
child.stderr.on('data', (chunk) => {
|
| 1218 |
stderr += chunk;
|
| 1219 |
});
|
| 1220 |
+
child.on('close', (status, signal) => {
|
| 1221 |
+
if (timeout) clearTimeout(timeout);
|
| 1222 |
+
resolve({ status, signal, stdout, stderr, timedOut });
|
| 1223 |
});
|
| 1224 |
});
|
| 1225 |
}
|
|
|
|
| 1236 |
await new Promise((resolve) => server.close(resolve));
|
| 1237 |
}
|
| 1238 |
}
|
| 1239 |
+
|
| 1240 |
+
function readRequestText(request) {
|
| 1241 |
+
return new Promise((resolve, reject) => {
|
| 1242 |
+
let body = '';
|
| 1243 |
+
request.setEncoding('utf8');
|
| 1244 |
+
request.on('data', (chunk) => {
|
| 1245 |
+
body += chunk;
|
| 1246 |
+
});
|
| 1247 |
+
request.on('end', () => {
|
| 1248 |
+
resolve(body);
|
| 1249 |
+
});
|
| 1250 |
+
request.on('error', reject);
|
| 1251 |
+
});
|
| 1252 |
+
}
|
scripts/command-center-utils.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { spawnSync } from 'node:child_process';
|
| 2 |
+
import { resolve } from 'node:path';
|
| 3 |
+
import { pathToFileURL } from 'node:url';
|
| 4 |
+
|
| 5 |
+
const DEFAULT_MAX_BUFFER = 20 * 1024 * 1024;
|
| 6 |
+
const DEFAULT_FETCH_TIMEOUT_MS = 5000;
|
| 7 |
+
|
| 8 |
+
export function isMainModule(moduleUrl, argvPath) {
|
| 9 |
+
return Boolean(argvPath) && moduleUrl === pathToFileURL(resolve(argvPath)).href;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function runCommand(command, args = [], options = {}) {
|
| 13 |
+
const startedAt = Date.now();
|
| 14 |
+
const result = spawnSync(command, args, {
|
| 15 |
+
cwd: options.cwd,
|
| 16 |
+
encoding: options.encoding || 'utf8',
|
| 17 |
+
env: options.env,
|
| 18 |
+
input: options.input,
|
| 19 |
+
maxBuffer: options.maxBuffer || DEFAULT_MAX_BUFFER,
|
| 20 |
+
stdio: ['pipe', 'pipe', 'pipe'],
|
| 21 |
+
timeout: options.timeoutMs
|
| 22 |
+
});
|
| 23 |
+
const elapsedMs = Date.now() - startedAt;
|
| 24 |
+
if (result.error) {
|
| 25 |
+
return {
|
| 26 |
+
ok: false,
|
| 27 |
+
command,
|
| 28 |
+
args,
|
| 29 |
+
status: result.status,
|
| 30 |
+
stdout: String(result.stdout || ''),
|
| 31 |
+
stderr: String(result.stderr || ''),
|
| 32 |
+
elapsed_ms: elapsedMs,
|
| 33 |
+
error: result.error.message,
|
| 34 |
+
signal: result.signal
|
| 35 |
+
};
|
| 36 |
+
}
|
| 37 |
+
return {
|
| 38 |
+
ok: result.status === 0,
|
| 39 |
+
command,
|
| 40 |
+
args,
|
| 41 |
+
status: result.status,
|
| 42 |
+
stdout: String(result.stdout || ''),
|
| 43 |
+
stderr: String(result.stderr || ''),
|
| 44 |
+
elapsed_ms: elapsedMs,
|
| 45 |
+
signal: result.signal
|
| 46 |
+
};
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function runCommandStrict(command, args = [], options = {}) {
|
| 50 |
+
const result = runCommand(command, args, options);
|
| 51 |
+
if (!result.ok) throw new Error(commandFailureMessage(result));
|
| 52 |
+
return result.stdout;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
export function commandFailureMessage(result) {
|
| 56 |
+
return pickFailureOutput(result) || `${result.command} ${result.args.join(' ')} failed`;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
export function printJson(payload) {
|
| 60 |
+
console.log(JSON.stringify(payload, null, 2));
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
export function parseJsonPayload(output, label = 'command') {
|
| 64 |
+
const lines = output.split(/\r?\n/);
|
| 65 |
+
let lastError;
|
| 66 |
+
let sawCandidate = false;
|
| 67 |
+
for (let start = 0; start < lines.length; start += 1) {
|
| 68 |
+
const trimmed = lines[start].trimStart();
|
| 69 |
+
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) continue;
|
| 70 |
+
sawCandidate = true;
|
| 71 |
+
for (let end = lines.length; end > start; end -= 1) {
|
| 72 |
+
try {
|
| 73 |
+
return JSON.parse(lines.slice(start, end).join('\n'));
|
| 74 |
+
} catch (error) {
|
| 75 |
+
lastError = error;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
if (!sawCandidate) throw new Error(`${label} did not return JSON output.`);
|
| 80 |
+
throw new Error(`${label} returned invalid JSON: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
export async function fetchJsonWithTimeout(url, options = {}) {
|
| 84 |
+
const timeoutMs = options.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS;
|
| 85 |
+
const pathname = safeUrlPathname(url);
|
| 86 |
+
const controller = new AbortController();
|
| 87 |
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
| 88 |
+
try {
|
| 89 |
+
const response = await fetch(url, { signal: controller.signal });
|
| 90 |
+
const text = await response.text();
|
| 91 |
+
const bodySnippet = formatResponseBodySnippet(text);
|
| 92 |
+
if (!response.ok) throw new Error(`${pathname} failed with HTTP ${response.status}${bodySnippet}`);
|
| 93 |
+
try {
|
| 94 |
+
return JSON.parse(text);
|
| 95 |
+
} catch (error) {
|
| 96 |
+
if (error instanceof SyntaxError) throw new Error(`${pathname} did not return JSON${bodySnippet}`);
|
| 97 |
+
throw error;
|
| 98 |
+
}
|
| 99 |
+
} catch (error) {
|
| 100 |
+
if (error?.name === 'AbortError') throw new Error(`${pathname} timed out after ${timeoutMs}ms.`);
|
| 101 |
+
throw error;
|
| 102 |
+
} finally {
|
| 103 |
+
clearTimeout(timer);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
function formatResponseBodySnippet(text) {
|
| 108 |
+
if (!text || process.env.NODE_ENV === 'production') return '';
|
| 109 |
+
return `: ${text.slice(0, 100)}`;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function safeUrlPathname(url) {
|
| 113 |
+
try {
|
| 114 |
+
return new URL(url).pathname;
|
| 115 |
+
} catch {
|
| 116 |
+
return String(url);
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
export function pickFailureOutput(result, maxLength = 4000) {
|
| 121 |
+
const output = [
|
| 122 |
+
result.stdout,
|
| 123 |
+
result.stderr,
|
| 124 |
+
result.status !== undefined && result.status !== null ? `status: ${result.status}` : '',
|
| 125 |
+
result.error ? `error: ${result.error}` : '',
|
| 126 |
+
result.signal ? `signal: ${result.signal}` : ''
|
| 127 |
+
]
|
| 128 |
+
.filter(Boolean)
|
| 129 |
+
.join('\n')
|
| 130 |
+
.trim();
|
| 131 |
+
return output.length > maxLength ? `${output.slice(0, maxLength)}...` : output;
|
| 132 |
+
}
|
scripts/command-center.test.mjs
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { createServer } from 'node:http';
|
| 3 |
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
| 4 |
+
import os from 'node:os';
|
| 5 |
+
import path from 'node:path';
|
| 6 |
+
import { describe, it } from 'node:test';
|
| 7 |
+
|
| 8 |
+
import { buildAgentDoctorArgs } from './agent-doctor.mjs';
|
| 9 |
+
import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs';
|
| 10 |
+
import {
|
| 11 |
+
buildAdminCommands,
|
| 12 |
+
buildImageUpstreamRealSmokeStatus,
|
| 13 |
+
parseGitStatusEntries,
|
| 14 |
+
readStatusEnvFromFiles,
|
| 15 |
+
readRemoteStatusFromResult
|
| 16 |
+
} from './status.mjs';
|
| 17 |
+
import { assertLocalProbeMatchesMode, buildDockerComposeArgs, buildDockerComposeEnv } from './deploy-local.mjs';
|
| 18 |
+
import { buildVerifyPlan } from './verify.mjs';
|
| 19 |
+
|
| 20 |
+
describe('Command center scripts', () => {
|
| 21 |
+
it('exposes a small stable administrator command set', () => {
|
| 22 |
+
assert.deepEqual(buildAdminCommands(), {
|
| 23 |
+
doctor: 'npm run doctor',
|
| 24 |
+
status: 'npm run status',
|
| 25 |
+
verify: 'npm run verify',
|
| 26 |
+
deploy_local: 'npm run deploy:local',
|
| 27 |
+
deploy_space: 'npm run deploy:space',
|
| 28 |
+
agent_doctor: 'npm run agent:doctor',
|
| 29 |
+
hf_space_doctor: 'npm run doctor:hf-space',
|
| 30 |
+
hf_space_smoke: 'npm run smoke:hf-space'
|
| 31 |
+
});
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
+
it('keeps the full verification gate aligned with repository policy', () => {
|
| 35 |
+
assert.deepEqual(
|
| 36 |
+
buildVerifyPlan().map((step) => step.name),
|
| 37 |
+
['test', 'lint', 'lint:scripts', 'build', 'diff-check', 'diff-cached-check']
|
| 38 |
+
);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it('supports a quick verification loop without hiding the full gate', () => {
|
| 42 |
+
assert.deepEqual(
|
| 43 |
+
buildVerifyPlan({ quick: true }).map((step) => step.name),
|
| 44 |
+
['test:scripts', 'lint:scripts', 'diff-check', 'diff-cached-check']
|
| 45 |
+
);
|
| 46 |
+
assert.deepEqual(
|
| 47 |
+
buildVerifyPlan({ skipBuild: true }).map((step) => step.name),
|
| 48 |
+
['test', 'lint', 'lint:scripts', 'diff-check', 'diff-cached-check']
|
| 49 |
+
);
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
it('can include the live PostgreSQL gate before the final diff check', () => {
|
| 53 |
+
assert.deepEqual(
|
| 54 |
+
buildVerifyPlan({ postgres: true }).map((step) => step.name),
|
| 55 |
+
['test', 'lint', 'lint:scripts', 'build', 'test:postgres', 'diff-check', 'diff-cached-check']
|
| 56 |
+
);
|
| 57 |
+
assert.deepEqual(
|
| 58 |
+
buildVerifyPlan({ quick: true, postgres: true }).map((step) => step.name),
|
| 59 |
+
['test:scripts', 'lint:scripts', 'test:postgres', 'diff-check', 'diff-cached-check']
|
| 60 |
+
);
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
it('parses NUL-delimited git porcelain paths without rewriting filenames', () => {
|
| 64 |
+
const output = [
|
| 65 |
+
' M README.md',
|
| 66 |
+
'?? path with spaces.md',
|
| 67 |
+
'R new name.md',
|
| 68 |
+
'old name.md',
|
| 69 |
+
' M path -> not rename.md',
|
| 70 |
+
' M line\nbreak.md',
|
| 71 |
+
''
|
| 72 |
+
].join('\0');
|
| 73 |
+
|
| 74 |
+
assert.deepEqual(parseGitStatusEntries(output), [
|
| 75 |
+
'README.md',
|
| 76 |
+
'path with spaces.md',
|
| 77 |
+
'new name.md',
|
| 78 |
+
'path -> not rename.md',
|
| 79 |
+
'line\nbreak.md'
|
| 80 |
+
]);
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it('summarizes independent image upstream smoke readiness without exposing credentials', () => {
|
| 84 |
+
const missing = buildImageUpstreamRealSmokeStatus({});
|
| 85 |
+
assert.equal(missing.configuration_complete, false);
|
| 86 |
+
assert.equal(missing.configured_count, 0);
|
| 87 |
+
assert.deepEqual(missing.missing_cases, [
|
| 88 |
+
'original-images-json',
|
| 89 |
+
'gaoren-images-sse',
|
| 90 |
+
'sub2api-images-sse',
|
| 91 |
+
'sub2api-responses-json',
|
| 92 |
+
'gpt2image-responses-sse'
|
| 93 |
+
]);
|
| 94 |
+
assert.deepEqual(missing.missing_env_any['sub2api-responses-json'][0], [
|
| 95 |
+
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL',
|
| 96 |
+
'IMAGE_REAL_SMOKE_SUB2API_BASE_URL'
|
| 97 |
+
]);
|
| 98 |
+
assert.equal(
|
| 99 |
+
missing.final_gate_command,
|
| 100 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable'
|
| 101 |
+
);
|
| 102 |
+
|
| 103 |
+
const configured = buildImageUpstreamRealSmokeStatus({
|
| 104 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://original.example/v1',
|
| 105 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'original-secret',
|
| 106 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL: 'https://gaoren.example/v1',
|
| 107 |
+
IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'gaoren-secret',
|
| 108 |
+
IMAGE_REAL_SMOKE_SUB2API_BASE_URL: 'https://sub2api.example/v1',
|
| 109 |
+
IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'sub2api-secret',
|
| 110 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL: 'gpt-4.1',
|
| 111 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: 'https://gpt2image.example/v1',
|
| 112 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'gpt2image-secret',
|
| 113 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL: 'gpt-5.4'
|
| 114 |
+
});
|
| 115 |
+
assert.equal(configured.configuration_complete, true);
|
| 116 |
+
assert.equal(configured.configured_count, 5);
|
| 117 |
+
assert.equal(configured.missing_count, 0);
|
| 118 |
+
assert.doesNotMatch(JSON.stringify(configured), /secret|example\/v1/);
|
| 119 |
+
});
|
| 120 |
+
|
| 121 |
+
it('requires Responses top-level models in image upstream status readiness', () => {
|
| 122 |
+
const status = buildImageUpstreamRealSmokeStatus({
|
| 123 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://original.example/v1',
|
| 124 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'original-secret',
|
| 125 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL: 'https://gaoren.example/v1',
|
| 126 |
+
IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'gaoren-secret',
|
| 127 |
+
IMAGE_REAL_SMOKE_SUB2API_BASE_URL: 'https://sub2api.example/v1',
|
| 128 |
+
IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'sub2api-secret',
|
| 129 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: 'https://gpt2image.example/v1',
|
| 130 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'gpt2image-secret'
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
assert.equal(status.configuration_complete, false);
|
| 134 |
+
assert.equal(status.configured_count, 3);
|
| 135 |
+
assert.deepEqual(status.configured_cases, [
|
| 136 |
+
'original-images-json',
|
| 137 |
+
'gaoren-images-sse',
|
| 138 |
+
'sub2api-images-sse'
|
| 139 |
+
]);
|
| 140 |
+
assert.deepEqual(status.missing_cases, ['sub2api-responses-json', 'gpt2image-responses-sse']);
|
| 141 |
+
assert.deepEqual(status.missing_env_any['sub2api-responses-json'][0], [
|
| 142 |
+
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL',
|
| 143 |
+
'OPENAI_RESPONSES_API_MODEL'
|
| 144 |
+
]);
|
| 145 |
+
assert.deepEqual(status.missing_env_any['gpt2image-responses-sse'][0], [
|
| 146 |
+
'IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL',
|
| 147 |
+
'OPENAI_RESPONSES_API_MODEL'
|
| 148 |
+
]);
|
| 149 |
+
assert.doesNotMatch(JSON.stringify(status), /secret|example\/v1/);
|
| 150 |
+
});
|
| 151 |
+
|
| 152 |
+
it('does not treat the sub2api Responses image model as the Responses top-level model', () => {
|
| 153 |
+
const status = buildImageUpstreamRealSmokeStatus({
|
| 154 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: 'https://sub2api-responses.example/v1',
|
| 155 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'sub2api-responses-secret',
|
| 156 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL: 'gpt-image-2'
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
assert.equal(status.configuration_complete, false);
|
| 160 |
+
assert.equal(status.configured_count, 0);
|
| 161 |
+
assert.deepEqual(status.missing_cases, [
|
| 162 |
+
'original-images-json',
|
| 163 |
+
'gaoren-images-sse',
|
| 164 |
+
'sub2api-images-sse',
|
| 165 |
+
'sub2api-responses-json',
|
| 166 |
+
'gpt2image-responses-sse'
|
| 167 |
+
]);
|
| 168 |
+
assert.deepEqual(status.missing_env_any['sub2api-responses-json'][0], [
|
| 169 |
+
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL',
|
| 170 |
+
'OPENAI_RESPONSES_API_MODEL'
|
| 171 |
+
]);
|
| 172 |
+
assert.doesNotMatch(JSON.stringify(status), /sub2api-responses-secret|sub2api-responses\.example/);
|
| 173 |
+
});
|
| 174 |
+
|
| 175 |
+
it('reports unsafe independent image upstream base URLs without exposing values', () => {
|
| 176 |
+
const status = buildImageUpstreamRealSmokeStatus({
|
| 177 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@original.example/v1?token=secret#frag',
|
| 178 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'original-secret'
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
assert.equal(status.configuration_complete, false);
|
| 182 |
+
assert.equal(status.configured_count, 0);
|
| 183 |
+
assert.equal(status.missing_count, 4);
|
| 184 |
+
assert.equal(status.invalid_count, 1);
|
| 185 |
+
assert.deepEqual(status.invalid_cases, ['original-images-json']);
|
| 186 |
+
assert.deepEqual(status.invalid_env['original-images-json'], [
|
| 187 |
+
{
|
| 188 |
+
key: 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL',
|
| 189 |
+
reason: 'must_not_include_credentials'
|
| 190 |
+
}
|
| 191 |
+
]);
|
| 192 |
+
assert.equal(status.missing_env_any['original-images-json'], undefined);
|
| 193 |
+
assert.doesNotMatch(JSON.stringify(status), /user:pass|original\.example|token=secret|original-secret/);
|
| 194 |
+
});
|
| 195 |
+
|
| 196 |
+
it('loads independent image upstream smoke readiness from env files without overriding shell env', async () => {
|
| 197 |
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-upstream-status-'));
|
| 198 |
+
const localEnvPath = path.join(tempDir, '.env.local');
|
| 199 |
+
const realSmokeEnvPath = path.join(tempDir, '.env.real-smoke.local');
|
| 200 |
+
await writeFile(
|
| 201 |
+
localEnvPath,
|
| 202 |
+
[
|
| 203 |
+
'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://local-original.example/v1',
|
| 204 |
+
'IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=local-original-secret',
|
| 205 |
+
'IMAGE_REAL_SMOKE_GAOREN_BASE_URL=https://local-gaoren.example/v1',
|
| 206 |
+
'IMAGE_REAL_SMOKE_GAOREN_API_KEY=local-gaoren-secret'
|
| 207 |
+
].join('\n')
|
| 208 |
+
);
|
| 209 |
+
await writeFile(
|
| 210 |
+
realSmokeEnvPath,
|
| 211 |
+
[
|
| 212 |
+
'IMAGE_REAL_SMOKE_GAOREN_BASE_URL=https://real-gaoren.example/v1',
|
| 213 |
+
'IMAGE_REAL_SMOKE_GAOREN_API_KEY=real-gaoren-secret',
|
| 214 |
+
'IMAGE_REAL_SMOKE_SUB2API_BASE_URL=https://real-sub2api.example/v1',
|
| 215 |
+
'IMAGE_REAL_SMOKE_SUB2API_API_KEY=real-sub2api-secret',
|
| 216 |
+
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL=gpt-4.1',
|
| 217 |
+
'IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://real-gpt2image.example/v1',
|
| 218 |
+
'IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=real-gpt2image-secret',
|
| 219 |
+
'IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4'
|
| 220 |
+
].join('\n')
|
| 221 |
+
);
|
| 222 |
+
|
| 223 |
+
try {
|
| 224 |
+
const statusEnv = readStatusEnvFromFiles(
|
| 225 |
+
{
|
| 226 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'shell-original-secret'
|
| 227 |
+
},
|
| 228 |
+
[
|
| 229 |
+
{ path: localEnvPath, override: false },
|
| 230 |
+
{ path: realSmokeEnvPath, override: true }
|
| 231 |
+
]
|
| 232 |
+
);
|
| 233 |
+
const status = buildImageUpstreamRealSmokeStatus(statusEnv);
|
| 234 |
+
|
| 235 |
+
assert.equal(statusEnv.IMAGE_REAL_SMOKE_ORIGINAL_API_KEY, 'shell-original-secret');
|
| 236 |
+
assert.equal(status.configuration_complete, true);
|
| 237 |
+
assert.deepEqual(status.configured_cases, [
|
| 238 |
+
'original-images-json',
|
| 239 |
+
'gaoren-images-sse',
|
| 240 |
+
'sub2api-images-sse',
|
| 241 |
+
'sub2api-responses-json',
|
| 242 |
+
'gpt2image-responses-sse'
|
| 243 |
+
]);
|
| 244 |
+
assert.doesNotMatch(JSON.stringify(status), /secret|example\/v1/);
|
| 245 |
+
} finally {
|
| 246 |
+
await rm(tempDir, { recursive: true, force: true });
|
| 247 |
+
}
|
| 248 |
+
});
|
| 249 |
+
|
| 250 |
+
it('preserves mismatched env quotes in status env files', async () => {
|
| 251 |
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-upstream-status-'));
|
| 252 |
+
const envPath = path.join(tempDir, '.env.local');
|
| 253 |
+
await writeFile(
|
| 254 |
+
envPath,
|
| 255 |
+
[
|
| 256 |
+
'MATCHED_DOUBLE="https://quoted.example/v1"',
|
| 257 |
+
"MATCHED_SINGLE='single-secret'",
|
| 258 |
+
'MATCHED_DOUBLE_SPACES=" kept value "',
|
| 259 |
+
'MISMATCHED_LEADING="kept-value',
|
| 260 |
+
'MISMATCHED_TRAILING=kept-value"',
|
| 261 |
+
'MISMATCHED_PAIR="kept-value\'',
|
| 262 |
+
'SINGLE_DOUBLE_QUOTE="',
|
| 263 |
+
"SINGLE_SINGLE_QUOTE='"
|
| 264 |
+
].join('\n')
|
| 265 |
+
);
|
| 266 |
+
|
| 267 |
+
try {
|
| 268 |
+
const statusEnv = readStatusEnvFromFiles({}, [{ path: envPath, override: false }]);
|
| 269 |
+
|
| 270 |
+
assert.equal(statusEnv.MATCHED_DOUBLE, 'https://quoted.example/v1');
|
| 271 |
+
assert.equal(statusEnv.MATCHED_SINGLE, 'single-secret');
|
| 272 |
+
assert.equal(statusEnv.MATCHED_DOUBLE_SPACES, ' kept value ');
|
| 273 |
+
assert.equal(statusEnv.MISMATCHED_LEADING, '"kept-value');
|
| 274 |
+
assert.equal(statusEnv.MISMATCHED_TRAILING, 'kept-value"');
|
| 275 |
+
assert.equal(statusEnv.MISMATCHED_PAIR, '"kept-value\'');
|
| 276 |
+
assert.equal(statusEnv.SINGLE_DOUBLE_QUOTE, '"');
|
| 277 |
+
assert.equal(statusEnv.SINGLE_SINGLE_QUOTE, "'");
|
| 278 |
+
} finally {
|
| 279 |
+
await rm(tempDir, { recursive: true, force: true });
|
| 280 |
+
}
|
| 281 |
+
});
|
| 282 |
+
|
| 283 |
+
it('builds deterministic local deploy compose arguments', () => {
|
| 284 |
+
assert.deepEqual(buildDockerComposeArgs(), ['compose', '-f', 'docker-compose.yml', 'up', '-d', '--build']);
|
| 285 |
+
assert.deepEqual(buildDockerComposeArgs({ memory: true }), [
|
| 286 |
+
'compose',
|
| 287 |
+
'-f',
|
| 288 |
+
'docker-compose.yml',
|
| 289 |
+
'-f',
|
| 290 |
+
'docker-compose.memory.yml',
|
| 291 |
+
'up',
|
| 292 |
+
'-d',
|
| 293 |
+
'--build'
|
| 294 |
+
]);
|
| 295 |
+
});
|
| 296 |
+
|
| 297 |
+
it('uses plain compose progress for diagnosable local deploy output', () => {
|
| 298 |
+
assert.deepEqual(buildDockerComposeEnv({ PATH: '/bin', COMPOSE_PROGRESS: 'auto' }), {
|
| 299 |
+
PATH: '/bin',
|
| 300 |
+
COMPOSE_PROGRESS: 'plain'
|
| 301 |
+
});
|
| 302 |
+
});
|
| 303 |
+
|
| 304 |
+
it('fails local memory deploy probes when the overlay did not take effect', () => {
|
| 305 |
+
assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }));
|
| 306 |
+
assert.doesNotThrow(() =>
|
| 307 |
+
assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }, { memory: true })
|
| 308 |
+
);
|
| 309 |
+
assert.throws(
|
| 310 |
+
() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { memory: true }),
|
| 311 |
+
/Memory overlay did not take effect/
|
| 312 |
+
);
|
| 313 |
+
});
|
| 314 |
+
|
| 315 |
+
it('routes agent:doctor through the non-billable contract check', () => {
|
| 316 |
+
const args = buildAgentDoctorArgs();
|
| 317 |
+
assert.match(args[0], /generate-image\.mjs$/);
|
| 318 |
+
assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']);
|
| 319 |
+
});
|
| 320 |
+
|
| 321 |
+
it('preserves raw child output for command consumers', () => {
|
| 322 |
+
const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']);
|
| 323 |
+
|
| 324 |
+
assert.equal(result.ok, true);
|
| 325 |
+
assert.equal(result.stdout, ' M README.md\n');
|
| 326 |
+
});
|
| 327 |
+
|
| 328 |
+
it('parses JSON output after command preamble lines', () => {
|
| 329 |
+
assert.deepEqual(parseJsonPayload('hint\n{"ok":true}', 'example'), { ok: true });
|
| 330 |
+
assert.deepEqual(parseJsonPayload('hint\n{"ok":true}\ntrailing log', 'example'), { ok: true });
|
| 331 |
+
assert.deepEqual(parseJsonPayload('[WARN] not json\n{"ok":true}', 'example'), { ok: true });
|
| 332 |
+
assert.throws(() => parseJsonPayload('hint only', 'example'), /example did not return JSON output/);
|
| 333 |
+
assert.throws(() => parseJsonPayload('hint\n{"ok":', 'example'), /example returned invalid JSON/);
|
| 334 |
+
});
|
| 335 |
+
|
| 336 |
+
it('times out local HTTP probes instead of hanging', async () => {
|
| 337 |
+
const server = createServer(() => {});
|
| 338 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 339 |
+
const address = server.address();
|
| 340 |
+
try {
|
| 341 |
+
assert.equal(typeof address, 'object');
|
| 342 |
+
await assert.rejects(
|
| 343 |
+
fetchJsonWithTimeout(`http://127.0.0.1:${address.port}/api/agent/capabilities`, { timeoutMs: 20 }),
|
| 344 |
+
/timed out after 20ms/
|
| 345 |
+
);
|
| 346 |
+
} finally {
|
| 347 |
+
await new Promise((resolve) => server.close(resolve));
|
| 348 |
+
}
|
| 349 |
+
});
|
| 350 |
+
|
| 351 |
+
it('preserves invalid URL fetch errors while building timeout diagnostics', async () => {
|
| 352 |
+
await assert.rejects(() => fetchJsonWithTimeout('not a url', { timeoutMs: 20 }), /Failed to parse URL|Invalid URL/i);
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
it('includes a response snippet when HTTP probes return non-JSON bodies', async () => {
|
| 356 |
+
const server = createServer((request, response) => {
|
| 357 |
+
response.writeHead(200, { 'content-type': 'text/html' });
|
| 358 |
+
response.end('<html>login page</html>');
|
| 359 |
+
});
|
| 360 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 361 |
+
const address = server.address();
|
| 362 |
+
try {
|
| 363 |
+
assert.equal(typeof address, 'object');
|
| 364 |
+
await assert.rejects(
|
| 365 |
+
fetchJsonWithTimeout(`http://127.0.0.1:${address.port}/api/agent/capabilities`, { timeoutMs: 1000 }),
|
| 366 |
+
/did not return JSON: <html>login page<\/html>/
|
| 367 |
+
);
|
| 368 |
+
} finally {
|
| 369 |
+
await new Promise((resolve) => server.close(resolve));
|
| 370 |
+
}
|
| 371 |
+
});
|
| 372 |
+
|
| 373 |
+
it('redacts response snippets from production HTTP probe errors', async () => {
|
| 374 |
+
const originalNodeEnv = process.env.NODE_ENV;
|
| 375 |
+
process.env.NODE_ENV = 'production';
|
| 376 |
+
const server = createServer((request, response) => {
|
| 377 |
+
response.writeHead(502, { 'content-type': 'text/plain' });
|
| 378 |
+
response.end('secret upstream body');
|
| 379 |
+
});
|
| 380 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 381 |
+
const address = server.address();
|
| 382 |
+
try {
|
| 383 |
+
assert.equal(typeof address, 'object');
|
| 384 |
+
await assert.rejects(
|
| 385 |
+
fetchJsonWithTimeout(`http://127.0.0.1:${address.port}/api/agent/capabilities`, { timeoutMs: 1000 }),
|
| 386 |
+
(error) => {
|
| 387 |
+
assert.match(error.message, /failed with HTTP 502/);
|
| 388 |
+
assert.doesNotMatch(error.message, /secret upstream body/);
|
| 389 |
+
return true;
|
| 390 |
+
}
|
| 391 |
+
);
|
| 392 |
+
} finally {
|
| 393 |
+
if (originalNodeEnv === undefined) {
|
| 394 |
+
delete process.env.NODE_ENV;
|
| 395 |
+
} else {
|
| 396 |
+
process.env.NODE_ENV = originalNodeEnv;
|
| 397 |
+
}
|
| 398 |
+
await new Promise((resolve) => server.close(resolve));
|
| 399 |
+
}
|
| 400 |
+
});
|
| 401 |
+
|
| 402 |
+
it('supports command timeouts for long-running child processes', () => {
|
| 403 |
+
const result = runCommand(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { timeoutMs: 20 });
|
| 404 |
+
|
| 405 |
+
assert.equal(result.ok, false);
|
| 406 |
+
assert.equal(result.status, null);
|
| 407 |
+
assert.match(result.error, /timed out|ETIMEDOUT|SIGTERM/i);
|
| 408 |
+
assert.match(pickFailureOutput(result), /error:|signal:/);
|
| 409 |
+
});
|
| 410 |
+
|
| 411 |
+
it('includes spawn errors in failure summaries without stdout or stderr', () => {
|
| 412 |
+
const summary = pickFailureOutput({ stdout: '', stderr: '', error: 'spawn missing ENOENT', signal: null });
|
| 413 |
+
|
| 414 |
+
assert.equal(summary, 'error: spawn missing ENOENT');
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
it('includes non-zero exit status in failure summaries', () => {
|
| 418 |
+
const summary = pickFailureOutput({ stdout: '', stderr: 'bad option', status: 129, signal: null });
|
| 419 |
+
|
| 420 |
+
assert.equal(summary, 'bad option\nstatus: 129');
|
| 421 |
+
});
|
| 422 |
+
|
| 423 |
+
it('keeps local status usable when remote status JSON is malformed', () => {
|
| 424 |
+
assert.deepEqual(readRemoteStatusFromResult({ ok: true, stdout: 'not json' }), {
|
| 425 |
+
ok: false,
|
| 426 |
+
error: 'hf spaces info did not return JSON output.'
|
| 427 |
+
});
|
| 428 |
+
});
|
| 429 |
+
});
|
scripts/deploy-hf-space.mjs
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { spawnSync } from 'node:child_process';
|
| 4 |
+
import { mkdtempSync, rmSync } from 'node:fs';
|
| 5 |
+
import { tmpdir } from 'node:os';
|
| 6 |
+
import { join } from 'node:path';
|
| 7 |
+
import { setTimeout as delay } from 'node:timers/promises';
|
| 8 |
+
|
| 9 |
+
import { fetchJsonWithTimeout, parseJsonPayload, runCommandStrict } from './command-center-utils.mjs';
|
| 10 |
+
import { assertKnownOptions, HF_SPACE_ID, HF_SPACE_URL, isMainModule } from './hf-space-doctor-utils.mjs';
|
| 11 |
+
|
| 12 |
+
const STATUS_POLL_ATTEMPTS = 40;
|
| 13 |
+
const STATUS_POLL_INTERVAL_MS = 10_000;
|
| 14 |
+
const PUBLIC_ENDPOINT_TIMEOUT_MS = 10_000;
|
| 15 |
+
const HF_CLI_TIMEOUT_MS = 120_000;
|
| 16 |
+
export const GIT_ARCHIVE_MAX_BUFFER_BYTES = 256 * 1024 * 1024;
|
| 17 |
+
|
| 18 |
+
function parseArgs(argv) {
|
| 19 |
+
assertKnownOptions(argv, ['--help', '-h']);
|
| 20 |
+
return {
|
| 21 |
+
help: argv.includes('--help') || argv.includes('-h')
|
| 22 |
+
};
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
function printHelp() {
|
| 26 |
+
console.log(`Usage:
|
| 27 |
+
npm run deploy:hf-space
|
| 28 |
+
|
| 29 |
+
Deploys the current clean git HEAD to ${HF_SPACE_ID} with the official hf CLI.
|
| 30 |
+
|
| 31 |
+
The script uploads a temporary git archive, waits for the Space to run the new
|
| 32 |
+
Space commit, and performs read-only public endpoint checks.`);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function runText(command, args, options = {}) {
|
| 36 |
+
return runCommandStrict(command, args, {
|
| 37 |
+
input: options.input,
|
| 38 |
+
timeoutMs: options.timeoutMs || HF_CLI_TIMEOUT_MS
|
| 39 |
+
}).trim();
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function readRepositorySlug() {
|
| 43 |
+
const envSlug = process.env.REPO_SLUG?.trim();
|
| 44 |
+
if (envSlug) return envSlug;
|
| 45 |
+
try {
|
| 46 |
+
return parseRepositorySlug(runText('git', ['remote', 'get-url', 'origin']));
|
| 47 |
+
} catch (error) {
|
| 48 |
+
throw new Error(
|
| 49 |
+
`Unable to detect repository slug from git origin. Set REPO_SLUG=owner/repo. ${error instanceof Error ? error.message : String(error)}`
|
| 50 |
+
);
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
export function parseRepositorySlug(remoteUrl) {
|
| 55 |
+
const text = String(remoteUrl || '').trim();
|
| 56 |
+
const httpsMatch = text.match(/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
|
| 57 |
+
if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`;
|
| 58 |
+
const sshMatch = text.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
|
| 59 |
+
if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`;
|
| 60 |
+
throw new Error('Unable to detect repository slug from git origin URL. Set REPO_SLUG=owner/repo.');
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function runBinary(command, args) {
|
| 64 |
+
const result = spawnSync(command, args, {
|
| 65 |
+
encoding: 'buffer',
|
| 66 |
+
maxBuffer: GIT_ARCHIVE_MAX_BUFFER_BYTES,
|
| 67 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 68 |
+
});
|
| 69 |
+
if (result.error) throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`);
|
| 70 |
+
if (result.status !== 0) {
|
| 71 |
+
const output = result.stderr.toString('utf8').trim();
|
| 72 |
+
throw new Error(output || `${command} ${args.join(' ')} failed`);
|
| 73 |
+
}
|
| 74 |
+
return result.stdout;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
function assertCleanGitWorktree() {
|
| 78 |
+
const status = runText('git', ['status', '--porcelain']);
|
| 79 |
+
if (status) {
|
| 80 |
+
throw new Error('Refusing to deploy a dirty worktree. Commit or revert local changes first.');
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function prepareSourceTree() {
|
| 85 |
+
const sourceDir = mkdtempSync(join(tmpdir(), 'gpt-image-hf-space-'));
|
| 86 |
+
const archive = runBinary('git', ['archive', '--format=tar', 'HEAD']);
|
| 87 |
+
runText('tar', ['-x', '-C', sourceDir], { input: archive });
|
| 88 |
+
return sourceDir;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
export function extractUploadCommitSha(output) {
|
| 92 |
+
const payload = parseJsonPayload(output, 'hf upload');
|
| 93 |
+
const directSha = [payload.sha, payload.commit, payload.commitSha, payload.commit_sha].find((value) =>
|
| 94 |
+
/^[0-9a-f]{40}$/.test(String(value || ''))
|
| 95 |
+
);
|
| 96 |
+
if (directSha) return directSha;
|
| 97 |
+
|
| 98 |
+
const match = String(payload.url || '').match(/\/commit\/([0-9a-f]{40})$/);
|
| 99 |
+
if (!match) throw new Error('hf upload output did not include a Space commit SHA or commit URL.');
|
| 100 |
+
return match[1];
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export function buildUploadArgs({ sourceDir, localSha, repoSlug }) {
|
| 104 |
+
if (!repoSlug?.trim()) throw new Error('REPO_SLUG is required for deploy metadata.');
|
| 105 |
+
return [
|
| 106 |
+
'upload',
|
| 107 |
+
HF_SPACE_ID,
|
| 108 |
+
sourceDir,
|
| 109 |
+
'.',
|
| 110 |
+
'--repo-type',
|
| 111 |
+
'space',
|
| 112 |
+
'--commit-message',
|
| 113 |
+
`Deploy ${localSha.slice(0, 7)} to Docker Space`,
|
| 114 |
+
'--commit-description',
|
| 115 |
+
`Source: ${repoSlug}@${localSha}`,
|
| 116 |
+
'--json'
|
| 117 |
+
];
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function uploadSourceTree(sourceDir, localSha) {
|
| 121 |
+
const output = runText('hf', buildUploadArgs({ sourceDir, localSha, repoSlug: readRepositorySlug() }));
|
| 122 |
+
return extractUploadCommitSha(output);
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function readSpaceInfo() {
|
| 126 |
+
const output = runText('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json']);
|
| 127 |
+
return parseJsonPayload(output, 'hf spaces info');
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
async function waitForRunning(spaceCommitSha) {
|
| 131 |
+
let lastStage = 'unknown';
|
| 132 |
+
let lastSha = 'unknown';
|
| 133 |
+
for (let attempt = 1; attempt <= STATUS_POLL_ATTEMPTS; attempt += 1) {
|
| 134 |
+
const info = readSpaceInfo();
|
| 135 |
+
lastStage = info.runtime?.stage || 'unknown';
|
| 136 |
+
lastSha = info.sha || info.runtime?.raw?.sha || 'unknown';
|
| 137 |
+
console.log(`attempt=${attempt} stage=${lastStage} sha=${lastSha}`);
|
| 138 |
+
if (lastStage === 'RUNNING' && lastSha === spaceCommitSha) {
|
| 139 |
+
return { stage: lastStage, sha: lastSha };
|
| 140 |
+
}
|
| 141 |
+
await delay(STATUS_POLL_INTERVAL_MS);
|
| 142 |
+
}
|
| 143 |
+
throw new Error(`Space did not reach RUNNING for ${spaceCommitSha}; last stage=${lastStage} sha=${lastSha}`);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async function fetchJson(path) {
|
| 147 |
+
return fetchJsonWithTimeout(new URL(path, HF_SPACE_URL), { timeoutMs: PUBLIC_ENDPOINT_TIMEOUT_MS });
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
async function verifyPublicEndpoints() {
|
| 151 |
+
const authStatus = await fetchJson('/api/auth-status');
|
| 152 |
+
const capabilities = await fetchJson('/api/agent/capabilities');
|
| 153 |
+
const runtime = await fetchJson('/api/runtime-capabilities');
|
| 154 |
+
|
| 155 |
+
if (authStatus.passwordRequired !== true) {
|
| 156 |
+
throw new Error('/api/auth-status did not report passwordRequired=true.');
|
| 157 |
+
}
|
| 158 |
+
if (capabilities.defaults?.state_backend !== 'memory') {
|
| 159 |
+
throw new Error('/api/agent/capabilities did not report state_backend=memory.');
|
| 160 |
+
}
|
| 161 |
+
if (capabilities.storage?.image_storage_mode !== 'indexeddb') {
|
| 162 |
+
throw new Error('/api/agent/capabilities did not report image_storage_mode=indexeddb.');
|
| 163 |
+
}
|
| 164 |
+
return {
|
| 165 |
+
passwordRequired: authStatus.passwordRequired,
|
| 166 |
+
agentAuth: capabilities.auth,
|
| 167 |
+
stateBackend: capabilities.defaults.state_backend,
|
| 168 |
+
imageStorageMode: capabilities.storage.image_storage_mode,
|
| 169 |
+
streamingBatch: runtime.streamingBatch
|
| 170 |
+
};
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
async function deploy() {
|
| 174 |
+
assertCleanGitWorktree();
|
| 175 |
+
runText('hf', ['auth', 'whoami']);
|
| 176 |
+
const localSha = runText('git', ['rev-parse', 'HEAD']);
|
| 177 |
+
const sourceDir = prepareSourceTree();
|
| 178 |
+
try {
|
| 179 |
+
const spaceCommitSha = uploadSourceTree(sourceDir, localSha);
|
| 180 |
+
const runtime = await waitForRunning(spaceCommitSha);
|
| 181 |
+
const verification = await verifyPublicEndpoints();
|
| 182 |
+
console.log(
|
| 183 |
+
JSON.stringify(
|
| 184 |
+
{
|
| 185 |
+
ok: true,
|
| 186 |
+
spaceId: HF_SPACE_ID,
|
| 187 |
+
spaceUrl: HF_SPACE_URL,
|
| 188 |
+
localSha,
|
| 189 |
+
spaceCommitSha,
|
| 190 |
+
runtime,
|
| 191 |
+
verification
|
| 192 |
+
},
|
| 193 |
+
null,
|
| 194 |
+
2
|
| 195 |
+
)
|
| 196 |
+
);
|
| 197 |
+
} finally {
|
| 198 |
+
rmSync(sourceDir, { force: true, recursive: true });
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
async function main() {
|
| 203 |
+
const options = parseArgs(process.argv.slice(2));
|
| 204 |
+
if (options.help) {
|
| 205 |
+
printHelp();
|
| 206 |
+
return;
|
| 207 |
+
}
|
| 208 |
+
await deploy();
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
| 212 |
+
main().catch((error) => {
|
| 213 |
+
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
|
| 214 |
+
process.exit(1);
|
| 215 |
+
});
|
| 216 |
+
}
|
scripts/deploy-hf-space.test.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { describe, it } from 'node:test';
|
| 3 |
+
|
| 4 |
+
import { GIT_ARCHIVE_MAX_BUFFER_BYTES, buildUploadArgs, extractUploadCommitSha, parseRepositorySlug } from './deploy-hf-space.mjs';
|
| 5 |
+
|
| 6 |
+
describe('HF Space deploy script', () => {
|
| 7 |
+
it('extracts the Space commit SHA from hf upload JSON output', () => {
|
| 8 |
+
const sha = extractUploadCommitSha(
|
| 9 |
+
[
|
| 10 |
+
'Start hashing 189 files.',
|
| 11 |
+
'{"url":"https://huggingface.co/spaces/misonL/gpt-image-playground-customer/commit/32151b6aeaec0e59f14d1aaa87fba160ca8410df"}'
|
| 12 |
+
].join('\n')
|
| 13 |
+
);
|
| 14 |
+
|
| 15 |
+
assert.equal(sha, '32151b6aeaec0e59f14d1aaa87fba160ca8410df');
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
it('rejects upload output without a Space commit URL', () => {
|
| 19 |
+
assert.throws(() => extractUploadCommitSha('{"url":"https://huggingface.co/spaces/misonL/demo"}'), /commit SHA or commit URL/);
|
| 20 |
+
});
|
| 21 |
+
|
| 22 |
+
it('extracts the Space commit SHA from direct hf upload JSON fields', () => {
|
| 23 |
+
assert.equal(
|
| 24 |
+
extractUploadCommitSha('{"sha":"cccccccccccccccccccccccccccccccccccccccc"}'),
|
| 25 |
+
'cccccccccccccccccccccccccccccccccccccccc'
|
| 26 |
+
);
|
| 27 |
+
assert.equal(
|
| 28 |
+
extractUploadCommitSha('{"commit_sha":"dddddddddddddddddddddddddddddddddddddddd"}'),
|
| 29 |
+
'dddddddddddddddddddddddddddddddddddddddd'
|
| 30 |
+
);
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it('extracts the Space commit SHA when hf prints warning lines before JSON', () => {
|
| 34 |
+
const sha = extractUploadCommitSha(
|
| 35 |
+
[
|
| 36 |
+
'[WARN] retrying upload metadata',
|
| 37 |
+
'{"url":"https://huggingface.co/spaces/misonL/gpt-image-playground-customer/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}',
|
| 38 |
+
'done'
|
| 39 |
+
].join('\n')
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
assert.equal(sha, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
it('builds upload args without embedding newline characters in commit metadata', () => {
|
| 46 |
+
const args = buildUploadArgs({
|
| 47 |
+
sourceDir: '/tmp/source',
|
| 48 |
+
localSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
| 49 |
+
repoSlug: 'MisonL/gpt-image-playground-customer'
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
assert.equal(args.includes('--json'), true);
|
| 53 |
+
assert.equal(args[args.indexOf('--commit-message') + 1], 'Deploy bbbbbbb to Docker Space');
|
| 54 |
+
assert.equal(
|
| 55 |
+
args[args.indexOf('--commit-description') + 1],
|
| 56 |
+
'Source: MisonL/gpt-image-playground-customer@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
|
| 57 |
+
);
|
| 58 |
+
assert.equal(args.some((arg) => arg.includes('\n')), false);
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
it('uses a provided repository slug in upload commit metadata', () => {
|
| 62 |
+
const args = buildUploadArgs({
|
| 63 |
+
sourceDir: '/tmp/source',
|
| 64 |
+
localSha: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee',
|
| 65 |
+
repoSlug: 'owner/repo'
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
assert.equal(args[args.indexOf('--commit-description') + 1], 'Source: owner/repo@eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee');
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
it('parses GitHub repository slugs from common origin URL formats', () => {
|
| 72 |
+
assert.equal(parseRepositorySlug('https://github.com/MisonL/gpt-image-playground-customer.git'), 'MisonL/gpt-image-playground-customer');
|
| 73 |
+
assert.equal(parseRepositorySlug('git@github.com:MisonL/gpt-image-playground-customer.git'), 'MisonL/gpt-image-playground-customer');
|
| 74 |
+
assert.throws(() => parseRepositorySlug('not-a-github-url'), /Set REPO_SLUG=owner\/repo/);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it('requires an explicit repository slug for upload metadata', () => {
|
| 78 |
+
assert.throws(
|
| 79 |
+
() =>
|
| 80 |
+
buildUploadArgs({
|
| 81 |
+
sourceDir: '/tmp/source',
|
| 82 |
+
localSha: 'ffffffffffffffffffffffffffffffffffffffff'
|
| 83 |
+
}),
|
| 84 |
+
/REPO_SLUG is required/
|
| 85 |
+
);
|
| 86 |
+
});
|
| 87 |
+
|
| 88 |
+
it('keeps enough buffer for repository archives used by Space uploads', () => {
|
| 89 |
+
assert.ok(GIT_ARCHIVE_MAX_BUFFER_BYTES >= 128 * 1024 * 1024);
|
| 90 |
+
});
|
| 91 |
+
});
|
scripts/deploy-local.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { setTimeout as delay } from 'node:timers/promises';
|
| 4 |
+
|
| 5 |
+
import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
|
| 6 |
+
|
| 7 |
+
const LOCAL_BASE_URL = 'http://localhost:4783';
|
| 8 |
+
const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities'];
|
| 9 |
+
const PROBE_ATTEMPTS = 30;
|
| 10 |
+
const PROBE_INTERVAL_MS = 2000;
|
| 11 |
+
const PROBE_TIMEOUT_MS = 5000;
|
| 12 |
+
const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000;
|
| 13 |
+
|
| 14 |
+
export function buildDockerComposeArgs(options = {}) {
|
| 15 |
+
const files = ['-f', 'docker-compose.yml'];
|
| 16 |
+
if (options.memory) files.push('-f', 'docker-compose.memory.yml');
|
| 17 |
+
return ['compose', ...files, 'up', '-d', '--build'];
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export function buildDockerComposeEnv(env = process.env) {
|
| 21 |
+
return { ...env, COMPOSE_PROGRESS: 'plain' };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function parseArgs(argv) {
|
| 25 |
+
const unknown = argv.find((arg) => !['--help', '-h', '--memory', '--skip-probe'].includes(arg));
|
| 26 |
+
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 27 |
+
return {
|
| 28 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 29 |
+
memory: argv.includes('--memory'),
|
| 30 |
+
skipProbe: argv.includes('--skip-probe')
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function printHelp() {
|
| 35 |
+
console.log(`Usage:
|
| 36 |
+
npm run deploy:local
|
| 37 |
+
npm run deploy:local -- --memory
|
| 38 |
+
|
| 39 |
+
Options:
|
| 40 |
+
--memory Use docker-compose.memory.yml overlay for HF Space-like memory mode.
|
| 41 |
+
--skip-probe Rebuild and start the container without HTTP endpoint probes.
|
| 42 |
+
--help Show this help.`);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
async function fetchJson(path) {
|
| 46 |
+
return fetchJsonWithTimeout(new URL(path, LOCAL_BASE_URL), { timeoutMs: PROBE_TIMEOUT_MS });
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
async function waitForLocalEndpoints() {
|
| 50 |
+
let lastError = '';
|
| 51 |
+
for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt += 1) {
|
| 52 |
+
try {
|
| 53 |
+
const responses = {};
|
| 54 |
+
for (const path of PROBE_PATHS) responses[path] = await fetchJson(path);
|
| 55 |
+
return {
|
| 56 |
+
attempts: attempt,
|
| 57 |
+
baseUrl: LOCAL_BASE_URL,
|
| 58 |
+
authRequired: responses['/api/auth-status'].passwordRequired,
|
| 59 |
+
stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend,
|
| 60 |
+
imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode,
|
| 61 |
+
streamingBatch: responses['/api/runtime-capabilities'].streamingBatch
|
| 62 |
+
};
|
| 63 |
+
} catch (error) {
|
| 64 |
+
lastError = error instanceof Error ? error.message : String(error);
|
| 65 |
+
await delay(PROBE_INTERVAL_MS);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
throw new Error(`Local container did not pass HTTP probes: ${lastError}`);
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
export function assertLocalProbeMatchesMode(probe, options = {}) {
|
| 72 |
+
if (!options.memory) return;
|
| 73 |
+
const mismatches = [];
|
| 74 |
+
if (probe.stateBackend !== 'memory') mismatches.push(`stateBackend=${probe.stateBackend ?? '<missing>'} expected memory`);
|
| 75 |
+
if (probe.imageStorageMode !== 'indexeddb') {
|
| 76 |
+
mismatches.push(`imageStorageMode=${probe.imageStorageMode ?? '<missing>'} expected indexeddb`);
|
| 77 |
+
}
|
| 78 |
+
if (mismatches.length) throw new Error(`Memory overlay did not take effect: ${mismatches.join(', ')}.`);
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
async function main() {
|
| 82 |
+
const options = parseArgs(process.argv.slice(2));
|
| 83 |
+
if (options.help) {
|
| 84 |
+
printHelp();
|
| 85 |
+
return;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const docker = runCommand('docker', buildDockerComposeArgs(options), {
|
| 89 |
+
env: buildDockerComposeEnv(),
|
| 90 |
+
timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS
|
| 91 |
+
});
|
| 92 |
+
if (!docker.ok) {
|
| 93 |
+
printJson({ ok: false, phase: 'docker-compose', output: pickFailureOutput(docker) });
|
| 94 |
+
process.exit(1);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
if (options.skipProbe) {
|
| 98 |
+
printJson({ ok: true, phase: 'docker-compose', probe: 'skipped' });
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const probe = await waitForLocalEndpoints();
|
| 103 |
+
assertLocalProbeMatchesMode(probe, options);
|
| 104 |
+
printJson({ ok: true, phase: 'ready', probe });
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
| 108 |
+
main().catch((error) => {
|
| 109 |
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
| 110 |
+
process.exit(1);
|
| 111 |
+
});
|
| 112 |
+
}
|
scripts/doctor-hf-space.mjs
CHANGED
|
@@ -1,34 +1,32 @@
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
|
| 3 |
-
import { existsSync
|
| 4 |
import {
|
| 5 |
assertKnownOptions,
|
| 6 |
buildNextActions,
|
| 7 |
classifyRequiredAndRecommendedNames,
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
getJsonNames,
|
| 10 |
isMainModule,
|
| 11 |
-
|
| 12 |
-
parseAccessFile,
|
| 13 |
-
readEnvValue,
|
| 14 |
-
readOptionValue,
|
| 15 |
-
runCommand,
|
| 16 |
-
validateSpaceId,
|
| 17 |
validateSpaceUrl
|
| 18 |
} from './hf-space-doctor-utils.mjs';
|
| 19 |
|
| 20 |
const MIN_NODE_MAJOR = 20;
|
| 21 |
-
const REQUIRED_ACCESS_KEYS = ['HF_SPACE_ID', 'HF_SPACE_URL', 'HF_SPACE_SECRET_KEYS', 'APP_PASSWORD', 'AGENT_API_TOKEN'];
|
| 22 |
-
const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD'];
|
| 23 |
const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'];
|
| 24 |
const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL'];
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
const REQUIRED_SPACE_SECRETS = ['APP_PASSWORD', 'AGENT_API_TOKEN'];
|
| 26 |
const OPTIONAL_GENERATION_SECRETS = ['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS'];
|
| 27 |
|
| 28 |
function parseArgs(argv) {
|
| 29 |
-
assertKnownOptions(argv, ['--
|
| 30 |
return {
|
| 31 |
-
accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE,
|
| 32 |
help: argv.includes('--help') || argv.includes('-h'),
|
| 33 |
skipRemote: argv.includes('--skip-remote')
|
| 34 |
};
|
|
@@ -39,12 +37,8 @@ function printHelp() {
|
|
| 39 |
npm run doctor:hf-space
|
| 40 |
|
| 41 |
Options:
|
| 42 |
-
--access-file <path> Override the access file path.
|
| 43 |
--skip-remote Skip read-only Hugging Face remote checks.
|
| 44 |
-
--help Show this help.
|
| 45 |
-
|
| 46 |
-
Environment overrides:
|
| 47 |
-
HF_SPACE_ACCESS_FILE`);
|
| 48 |
}
|
| 49 |
|
| 50 |
function addCheck(checks, status, name, message, details = {}) {
|
|
@@ -61,7 +55,7 @@ function checkNode(checks) {
|
|
| 61 |
}
|
| 62 |
|
| 63 |
function checkCommand(checks, name, command, args, failureAction) {
|
| 64 |
-
const result =
|
| 65 |
if (result.ok) {
|
| 66 |
addCheck(checks, 'pass', name, `${command} is available.`, { version: result.stdout.split(/\r?\n/)[0] });
|
| 67 |
return true;
|
|
@@ -70,127 +64,44 @@ function checkCommand(checks, name, command, args, failureAction) {
|
|
| 70 |
return false;
|
| 71 |
}
|
| 72 |
|
| 73 |
-
function
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
return undefined;
|
| 79 |
-
}
|
| 80 |
-
|
| 81 |
-
let values;
|
| 82 |
-
try {
|
| 83 |
-
values = parseAccessFile(accessFile);
|
| 84 |
-
} catch (error) {
|
| 85 |
-
addCheck(checks, 'fail', 'access-file', 'Access file cannot be read.', {
|
| 86 |
-
error: error instanceof Error ? error.message : String(error)
|
| 87 |
-
});
|
| 88 |
-
return undefined;
|
| 89 |
-
}
|
| 90 |
-
|
| 91 |
-
addCheck(checks, 'pass', 'access-file', `Access file exists: ${accessFile}`);
|
| 92 |
-
|
| 93 |
-
if (process.platform !== 'win32') {
|
| 94 |
-
const mode = statSync(accessFile).mode & 0o777;
|
| 95 |
-
if ((mode & 0o077) === 0) {
|
| 96 |
-
addCheck(checks, 'pass', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}.`);
|
| 97 |
-
} else {
|
| 98 |
-
addCheck(checks, 'fail', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}; expected 600.`, {
|
| 99 |
-
action: `chmod 600 ${accessFile}`
|
| 100 |
-
});
|
| 101 |
-
}
|
| 102 |
-
}
|
| 103 |
-
|
| 104 |
-
const missing = missingKeys(REQUIRED_ACCESS_KEYS, values);
|
| 105 |
-
if (missing.length) {
|
| 106 |
-
addCheck(checks, 'fail', 'access-file-keys', `Access file is missing required keys: ${missing.join(', ')}.`);
|
| 107 |
-
} else {
|
| 108 |
-
addCheck(checks, 'pass', 'access-file-keys', 'Access file contains all required non-empty keys.');
|
| 109 |
-
}
|
| 110 |
-
|
| 111 |
-
const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => values.has(key));
|
| 112 |
-
if (forbidden.length) {
|
| 113 |
-
addCheck(checks, 'fail', 'access-file-forbidden-keys', `Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`);
|
| 114 |
-
} else {
|
| 115 |
-
addCheck(checks, 'pass', 'access-file-forbidden-keys', 'Access file does not contain Hugging Face account credentials.');
|
| 116 |
-
}
|
| 117 |
-
|
| 118 |
-
validateAccessValues(checks, values);
|
| 119 |
-
return values;
|
| 120 |
-
}
|
| 121 |
-
|
| 122 |
-
function validateAccessValues(checks, values) {
|
| 123 |
-
const spaceId = values.get('HF_SPACE_ID')?.trim();
|
| 124 |
-
const spaceIdError = validateSpaceId(spaceId);
|
| 125 |
-
if (!spaceIdError) {
|
| 126 |
-
addCheck(checks, 'pass', 'space-id', 'HF_SPACE_ID has namespace/space format.');
|
| 127 |
-
} else if (spaceId) {
|
| 128 |
-
addCheck(checks, 'fail', 'space-id', spaceIdError);
|
| 129 |
-
}
|
| 130 |
-
|
| 131 |
-
const spaceUrl = values.get('HF_SPACE_URL')?.trim();
|
| 132 |
-
if (spaceUrl) {
|
| 133 |
-
const spaceUrlError = validateSpaceUrl(spaceUrl);
|
| 134 |
-
if (spaceUrlError) {
|
| 135 |
-
addCheck(checks, 'fail', 'space-url', spaceUrlError);
|
| 136 |
-
} else {
|
| 137 |
-
addCheck(checks, 'pass', 'space-url', 'HF_SPACE_URL looks like a Hugging Face Space URL.');
|
| 138 |
-
}
|
| 139 |
-
}
|
| 140 |
-
|
| 141 |
-
const appPassword = values.get('APP_PASSWORD') || '';
|
| 142 |
-
const agentToken = values.get('AGENT_API_TOKEN') || '';
|
| 143 |
-
if (appPassword.length >= 16 && agentToken.length >= 24) {
|
| 144 |
-
addCheck(
|
| 145 |
-
checks,
|
| 146 |
-
'pass',
|
| 147 |
-
'generated-secrets',
|
| 148 |
-
'APP_PASSWORD access code and AGENT_API_TOKEN meet the minimum length checks.'
|
| 149 |
-
);
|
| 150 |
-
} else {
|
| 151 |
-
addCheck(
|
| 152 |
-
checks,
|
| 153 |
-
'fail',
|
| 154 |
-
'generated-secrets',
|
| 155 |
-
'APP_PASSWORD access code must be at least 16 chars and AGENT_API_TOKEN at least 24 chars.'
|
| 156 |
-
);
|
| 157 |
}
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
-
function checkRemote(checks,
|
| 161 |
if (skipRemote) {
|
| 162 |
addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.');
|
| 163 |
return;
|
| 164 |
}
|
| 165 |
-
if (!hfAvailable || !hfAuthenticated
|
| 166 |
-
addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI
|
| 167 |
return;
|
| 168 |
}
|
| 169 |
|
| 170 |
-
const
|
| 171 |
-
if (!spaceId) {
|
| 172 |
-
addCheck(checks, 'skip', 'remote-space', 'Remote checks require HF_SPACE_ID in the access file.');
|
| 173 |
-
return;
|
| 174 |
-
}
|
| 175 |
-
const info = runCommand('hf', ['spaces', 'info', spaceId, '--format', 'json']);
|
| 176 |
if (!info.ok) {
|
| 177 |
-
addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${
|
| 178 |
return;
|
| 179 |
}
|
| 180 |
-
addCheck(checks, 'pass', 'remote-space', `Space ${
|
| 181 |
checkRemoteNames(
|
| 182 |
checks,
|
| 183 |
-
|
| 184 |
'remote-variables',
|
| 185 |
-
['spaces', 'variables', 'list',
|
| 186 |
REQUIRED_SPACE_VARIABLES,
|
| 187 |
RECOMMENDED_SPACE_VARIABLES
|
| 188 |
);
|
| 189 |
-
checkRemoteSecrets(checks,
|
| 190 |
}
|
| 191 |
|
| 192 |
function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) {
|
| 193 |
-
const result =
|
| 194 |
if (!result.ok) {
|
| 195 |
addCheck(checks, 'warn', name, `Cannot list ${name} for ${spaceId}.`, { error: result.error });
|
| 196 |
return;
|
|
@@ -210,6 +121,7 @@ function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommende
|
|
| 210 |
if (missingRecommended.length) {
|
| 211 |
addCheck(checks, 'warn', name, `${name} missing recommended names: ${missingRecommended.join(', ')}.`);
|
| 212 |
}
|
|
|
|
| 213 |
} catch (error) {
|
| 214 |
addCheck(checks, 'warn', name, `Cannot parse ${name} JSON output.`, {
|
| 215 |
error: error instanceof Error ? error.message : String(error)
|
|
@@ -217,8 +129,22 @@ function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommende
|
|
| 217 |
}
|
| 218 |
}
|
| 219 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
function checkRemoteSecrets(checks, spaceId) {
|
| 221 |
-
const result =
|
| 222 |
if (!result.ok) {
|
| 223 |
addCheck(checks, 'warn', 'remote-secrets', `Cannot list remote secrets for ${spaceId}.`, { error: result.error });
|
| 224 |
return;
|
|
@@ -255,7 +181,7 @@ function main() {
|
|
| 255 |
checkNode(checks);
|
| 256 |
checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.');
|
| 257 |
const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.');
|
| 258 |
-
const hfAuth = hfAvailable ?
|
| 259 |
if (hfAvailable && hfAuth.ok) {
|
| 260 |
addCheck(checks, 'pass', 'hf-auth', 'hf CLI is authenticated.');
|
| 261 |
} else if (hfAvailable) {
|
|
@@ -270,19 +196,19 @@ function main() {
|
|
| 270 |
addCheck(checks, 'warn', 'node-modules', 'node_modules is missing; build, lint, test, and smoke commands require npm install.');
|
| 271 |
}
|
| 272 |
checkCommand(checks, 'git', 'git', ['--version'], 'git is missing; install git before cloning or pushing Space repos.');
|
| 273 |
-
const docker =
|
| 274 |
if (docker.ok) {
|
| 275 |
addCheck(checks, 'pass', 'docker', 'docker is available.', { version: docker.stdout.split(/\r?\n/)[0] });
|
| 276 |
} else {
|
| 277 |
addCheck(checks, 'warn', 'docker', 'Docker is unavailable; npm run smoke:hf-space will not work.', {
|
| 278 |
error: docker.error
|
| 279 |
});
|
| 280 |
-
const dockerCli =
|
| 281 |
if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.');
|
| 282 |
}
|
| 283 |
|
| 284 |
-
|
| 285 |
-
checkRemote(checks,
|
| 286 |
|
| 287 |
const failed = checks.some((check) => check.status === 'fail');
|
| 288 |
console.log(JSON.stringify({ ok: !failed, checks, nextActions: buildNextActions(checks) }, null, 2));
|
|
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
|
| 3 |
+
import { existsSync } from 'node:fs';
|
| 4 |
import {
|
| 5 |
assertKnownOptions,
|
| 6 |
buildNextActions,
|
| 7 |
classifyRequiredAndRecommendedNames,
|
| 8 |
+
HF_SPACE_ID,
|
| 9 |
+
HF_SPACE_URL,
|
| 10 |
+
getJsonKeyValues,
|
| 11 |
getJsonNames,
|
| 12 |
isMainModule,
|
| 13 |
+
runDoctorCommand,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
validateSpaceUrl
|
| 15 |
} from './hf-space-doctor-utils.mjs';
|
| 16 |
|
| 17 |
const MIN_NODE_MAJOR = 20;
|
|
|
|
|
|
|
| 18 |
const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'];
|
| 19 |
const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL'];
|
| 20 |
+
const REQUIRED_SPACE_VARIABLE_VALUES = new Map([
|
| 21 |
+
['AGENT_STATE_BACKEND', 'memory'],
|
| 22 |
+
['NEXT_PUBLIC_IMAGE_STORAGE_MODE', 'indexeddb']
|
| 23 |
+
]);
|
| 24 |
const REQUIRED_SPACE_SECRETS = ['APP_PASSWORD', 'AGENT_API_TOKEN'];
|
| 25 |
const OPTIONAL_GENERATION_SECRETS = ['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS'];
|
| 26 |
|
| 27 |
function parseArgs(argv) {
|
| 28 |
+
assertKnownOptions(argv, ['--help', '-h', '--skip-remote']);
|
| 29 |
return {
|
|
|
|
| 30 |
help: argv.includes('--help') || argv.includes('-h'),
|
| 31 |
skipRemote: argv.includes('--skip-remote')
|
| 32 |
};
|
|
|
|
| 37 |
npm run doctor:hf-space
|
| 38 |
|
| 39 |
Options:
|
|
|
|
| 40 |
--skip-remote Skip read-only Hugging Face remote checks.
|
| 41 |
+
--help Show this help.`);
|
|
|
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
function addCheck(checks, status, name, message, details = {}) {
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
function checkCommand(checks, name, command, args, failureAction) {
|
| 58 |
+
const result = runDoctorCommand(command, args);
|
| 59 |
if (result.ok) {
|
| 60 |
addCheck(checks, 'pass', name, `${command} is available.`, { version: result.stdout.split(/\r?\n/)[0] });
|
| 61 |
return true;
|
|
|
|
| 64 |
return false;
|
| 65 |
}
|
| 66 |
|
| 67 |
+
function checkConfiguredTarget(checks) {
|
| 68 |
+
const spaceUrlError = validateSpaceUrl(HF_SPACE_URL);
|
| 69 |
+
if (spaceUrlError) {
|
| 70 |
+
addCheck(checks, 'fail', 'space-target', spaceUrlError);
|
| 71 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
+
addCheck(checks, 'pass', 'space-target', `Using fixed Space target ${HF_SPACE_ID}.`, { spaceUrl: HF_SPACE_URL });
|
| 74 |
}
|
| 75 |
|
| 76 |
+
function checkRemote(checks, skipRemote, hfAvailable, hfAuthenticated) {
|
| 77 |
if (skipRemote) {
|
| 78 |
addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.');
|
| 79 |
return;
|
| 80 |
}
|
| 81 |
+
if (!hfAvailable || !hfAuthenticated) {
|
| 82 |
+
addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI and hf auth login.');
|
| 83 |
return;
|
| 84 |
}
|
| 85 |
|
| 86 |
+
const info = runDoctorCommand('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json']);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
if (!info.ok) {
|
| 88 |
+
addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${HF_SPACE_ID}.`, { error: info.error });
|
| 89 |
return;
|
| 90 |
}
|
| 91 |
+
addCheck(checks, 'pass', 'remote-space', `Space ${HF_SPACE_ID} is accessible.`);
|
| 92 |
checkRemoteNames(
|
| 93 |
checks,
|
| 94 |
+
HF_SPACE_ID,
|
| 95 |
'remote-variables',
|
| 96 |
+
['spaces', 'variables', 'list', HF_SPACE_ID, '--json'],
|
| 97 |
REQUIRED_SPACE_VARIABLES,
|
| 98 |
RECOMMENDED_SPACE_VARIABLES
|
| 99 |
);
|
| 100 |
+
checkRemoteSecrets(checks, HF_SPACE_ID);
|
| 101 |
}
|
| 102 |
|
| 103 |
function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) {
|
| 104 |
+
const result = runDoctorCommand('hf', args);
|
| 105 |
if (!result.ok) {
|
| 106 |
addCheck(checks, 'warn', name, `Cannot list ${name} for ${spaceId}.`, { error: result.error });
|
| 107 |
return;
|
|
|
|
| 121 |
if (missingRecommended.length) {
|
| 122 |
addCheck(checks, 'warn', name, `${name} missing recommended names: ${missingRecommended.join(', ')}.`);
|
| 123 |
}
|
| 124 |
+
checkRemoteVariableValues(checks, result.stdout);
|
| 125 |
} catch (error) {
|
| 126 |
addCheck(checks, 'warn', name, `Cannot parse ${name} JSON output.`, {
|
| 127 |
error: error instanceof Error ? error.message : String(error)
|
|
|
|
| 129 |
}
|
| 130 |
}
|
| 131 |
|
| 132 |
+
function checkRemoteVariableValues(checks, jsonText) {
|
| 133 |
+
const values = getJsonKeyValues(jsonText);
|
| 134 |
+
const mismatches = [];
|
| 135 |
+
for (const [key, expected] of REQUIRED_SPACE_VARIABLE_VALUES) {
|
| 136 |
+
const actual = values.get(key);
|
| 137 |
+
if (actual !== expected) mismatches.push(`${key}=${actual ?? '<missing>'} expected ${expected}`);
|
| 138 |
+
}
|
| 139 |
+
if (mismatches.length) {
|
| 140 |
+
addCheck(checks, 'fail', 'remote-variable-values', `Remote variable values are not Space-free compatible: ${mismatches.join(', ')}.`);
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
addCheck(checks, 'pass', 'remote-variable-values', 'Remote variable values match the Space-free runtime contract.');
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
function checkRemoteSecrets(checks, spaceId) {
|
| 147 |
+
const result = runDoctorCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']);
|
| 148 |
if (!result.ok) {
|
| 149 |
addCheck(checks, 'warn', 'remote-secrets', `Cannot list remote secrets for ${spaceId}.`, { error: result.error });
|
| 150 |
return;
|
|
|
|
| 181 |
checkNode(checks);
|
| 182 |
checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.');
|
| 183 |
const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.');
|
| 184 |
+
const hfAuth = hfAvailable ? runDoctorCommand('hf', ['auth', 'whoami']) : { ok: false };
|
| 185 |
if (hfAvailable && hfAuth.ok) {
|
| 186 |
addCheck(checks, 'pass', 'hf-auth', 'hf CLI is authenticated.');
|
| 187 |
} else if (hfAvailable) {
|
|
|
|
| 196 |
addCheck(checks, 'warn', 'node-modules', 'node_modules is missing; build, lint, test, and smoke commands require npm install.');
|
| 197 |
}
|
| 198 |
checkCommand(checks, 'git', 'git', ['--version'], 'git is missing; install git before cloning or pushing Space repos.');
|
| 199 |
+
const docker = runDoctorCommand('docker', ['version', '--format', '{{.Server.Version}}']);
|
| 200 |
if (docker.ok) {
|
| 201 |
addCheck(checks, 'pass', 'docker', 'docker is available.', { version: docker.stdout.split(/\r?\n/)[0] });
|
| 202 |
} else {
|
| 203 |
addCheck(checks, 'warn', 'docker', 'Docker is unavailable; npm run smoke:hf-space will not work.', {
|
| 204 |
error: docker.error
|
| 205 |
});
|
| 206 |
+
const dockerCli = runDoctorCommand('docker', ['--version']);
|
| 207 |
if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.');
|
| 208 |
}
|
| 209 |
|
| 210 |
+
checkConfiguredTarget(checks);
|
| 211 |
+
checkRemote(checks, options.skipRemote, hfAvailable, Boolean(hfAuth.ok));
|
| 212 |
|
| 213 |
const failed = checks.some((check) => check.status === 'fail');
|
| 214 |
console.log(JSON.stringify({ ok: !failed, checks, nextActions: buildNextActions(checks) }, null, 2));
|
scripts/doctor.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
|
| 5 |
+
import { isMainModule, parseJsonPayload, printJson, runCommand } from './command-center-utils.mjs';
|
| 6 |
+
|
| 7 |
+
const HF_DOCTOR_SCRIPT = fileURLToPath(new URL('./doctor-hf-space.mjs', import.meta.url));
|
| 8 |
+
const DOCTOR_TIMEOUT_MS = 120_000;
|
| 9 |
+
|
| 10 |
+
function parseArgs(argv) {
|
| 11 |
+
const unknown = argv.find((arg) => !['--help', '-h', '--skip-remote'].includes(arg));
|
| 12 |
+
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 13 |
+
return {
|
| 14 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 15 |
+
skipRemote: argv.includes('--skip-remote')
|
| 16 |
+
};
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function printHelp() {
|
| 20 |
+
console.log(`Usage:
|
| 21 |
+
npm run doctor
|
| 22 |
+
npm run doctor -- --skip-remote
|
| 23 |
+
|
| 24 |
+
Options:
|
| 25 |
+
--skip-remote Skip read-only Hugging Face remote checks.
|
| 26 |
+
--help Show this help.`);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function main() {
|
| 30 |
+
const options = parseArgs(process.argv.slice(2));
|
| 31 |
+
if (options.help) {
|
| 32 |
+
printHelp();
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
const args = [HF_DOCTOR_SCRIPT, ...(options.skipRemote ? ['--skip-remote'] : [])];
|
| 37 |
+
const result = runCommand(process.execPath, args, { timeoutMs: DOCTOR_TIMEOUT_MS });
|
| 38 |
+
const child = result.stdout ? parseJsonPayload(result.stdout, 'doctor:hf-space') : {};
|
| 39 |
+
const ok = result.ok && child.ok !== false;
|
| 40 |
+
printJson({
|
| 41 |
+
ok,
|
| 42 |
+
profile: 'local-and-hf-space',
|
| 43 |
+
checks: child.checks || [],
|
| 44 |
+
nextActions: child.nextActions || [],
|
| 45 |
+
...(ok ? {} : { error: child.error || result.stderr || result.error || 'doctor failed' })
|
| 46 |
+
});
|
| 47 |
+
if (!ok) process.exit(1);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
try {
|
| 51 |
+
if (isMainModule(import.meta.url, process.argv[1])) main();
|
| 52 |
+
} catch (error) {
|
| 53 |
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
| 54 |
+
process.exit(1);
|
| 55 |
+
}
|
scripts/env-utils.mjs
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function readPositiveIntegerEnv(name, defaultValue, minValue = 1) {
|
| 2 |
+
const rawValue = process.env[name]?.trim();
|
| 3 |
+
if (!rawValue) return defaultValue;
|
| 4 |
+
if (!/^\d+$/.test(rawValue)) {
|
| 5 |
+
throw new Error(`${name} must be a positive integer formatted as digits`);
|
| 6 |
+
}
|
| 7 |
+
const value = Number.parseInt(rawValue, 10);
|
| 8 |
+
if (!Number.isSafeInteger(value)) {
|
| 9 |
+
throw new Error(`${name} must be a safe integer <= ${Number.MAX_SAFE_INTEGER}`);
|
| 10 |
+
}
|
| 11 |
+
if (value < minValue) {
|
| 12 |
+
throw new Error(`${name} must be a positive integer greater than or equal to ${minValue}`);
|
| 13 |
+
}
|
| 14 |
+
return value;
|
| 15 |
+
}
|
scripts/env-utils.test.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { afterEach, describe, it } from 'node:test';
|
| 3 |
+
|
| 4 |
+
import { readPositiveIntegerEnv } from './env-utils.mjs';
|
| 5 |
+
|
| 6 |
+
const TEST_ENV_NAME = 'GPT_IMAGE_PLAYGROUND_TEST_INTEGER';
|
| 7 |
+
|
| 8 |
+
afterEach(() => {
|
| 9 |
+
delete process.env[TEST_ENV_NAME];
|
| 10 |
+
});
|
| 11 |
+
|
| 12 |
+
describe('Environment utilities', () => {
|
| 13 |
+
it('returns the default value when the env var is unset', () => {
|
| 14 |
+
assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30), 30);
|
| 15 |
+
});
|
| 16 |
+
|
| 17 |
+
it('returns valid integer env values', () => {
|
| 18 |
+
process.env[TEST_ENV_NAME] = '42';
|
| 19 |
+
|
| 20 |
+
assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30), 42);
|
| 21 |
+
});
|
| 22 |
+
|
| 23 |
+
it('accepts values exactly equal to the configured minimum', () => {
|
| 24 |
+
process.env[TEST_ENV_NAME] = '1000';
|
| 25 |
+
|
| 26 |
+
assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30, 1000), 1000);
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it('accepts zero when the configured minimum is zero', () => {
|
| 30 |
+
process.env[TEST_ENV_NAME] = '0';
|
| 31 |
+
|
| 32 |
+
assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30, 0), 0);
|
| 33 |
+
});
|
| 34 |
+
|
| 35 |
+
it('rejects non-digit values before parsing', () => {
|
| 36 |
+
process.env[TEST_ENV_NAME] = '12ms';
|
| 37 |
+
|
| 38 |
+
assert.throws(() => readPositiveIntegerEnv(TEST_ENV_NAME, 30), /formatted as digits/);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it('rejects values below the configured minimum', () => {
|
| 42 |
+
process.env[TEST_ENV_NAME] = '999';
|
| 43 |
+
|
| 44 |
+
assert.throws(() => readPositiveIntegerEnv(TEST_ENV_NAME, 30, 1000), /greater than or equal to 1000/);
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('rejects integers that exceed JavaScript safe integer range', () => {
|
| 48 |
+
process.env[TEST_ENV_NAME] = String(Number.MAX_SAFE_INTEGER + 1);
|
| 49 |
+
|
| 50 |
+
assert.throws(() => readPositiveIntegerEnv(TEST_ENV_NAME, 30), /safe integer/);
|
| 51 |
+
});
|
| 52 |
+
});
|
scripts/hf-space-doctor-utils.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import { spawnSync } from 'node:child_process';
|
| 2 |
-
import {
|
| 3 |
-
import { join, resolve } from 'node:path';
|
| 4 |
import { pathToFileURL } from 'node:url';
|
| 5 |
|
| 6 |
-
export const
|
|
|
|
|
|
|
| 7 |
|
| 8 |
export function readEnvValue(name) {
|
| 9 |
return process.env[name]?.trim() || undefined;
|
|
@@ -34,12 +35,16 @@ export function assertKnownOptions(argv, knownOptions) {
|
|
| 34 |
}
|
| 35 |
}
|
| 36 |
|
| 37 |
-
export function
|
| 38 |
const result = spawnSync(command, args, {
|
| 39 |
encoding: 'utf8',
|
| 40 |
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
|
|
| 41 |
});
|
| 42 |
-
if (result.error)
|
|
|
|
|
|
|
|
|
|
| 43 |
if (result.status !== 0) {
|
| 44 |
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 45 |
return { ok: false, error: output || `${command} ${args.join(' ')} failed` };
|
|
@@ -47,20 +52,6 @@ export function runCommand(command, args = []) {
|
|
| 47 |
return { ok: true, stdout: result.stdout.trim() };
|
| 48 |
}
|
| 49 |
|
| 50 |
-
export function parseAccessFile(accessFile) {
|
| 51 |
-
const values = new Map();
|
| 52 |
-
const text = readFileSync(accessFile, 'utf8');
|
| 53 |
-
for (const rawLine of text.split(/\r?\n/)) {
|
| 54 |
-
if (!rawLine || rawLine.startsWith('#')) continue;
|
| 55 |
-
const separatorIndex = rawLine.indexOf('=');
|
| 56 |
-
if (separatorIndex <= 0) continue;
|
| 57 |
-
const key = rawLine.slice(0, separatorIndex).trim();
|
| 58 |
-
const value = rawLine.slice(separatorIndex + 1);
|
| 59 |
-
if (key) values.set(key, value);
|
| 60 |
-
}
|
| 61 |
-
return values;
|
| 62 |
-
}
|
| 63 |
-
|
| 64 |
export function getJsonNames(text) {
|
| 65 |
const parsed = JSON.parse(extractJsonPayload(text));
|
| 66 |
const names = new Set();
|
|
@@ -78,6 +69,23 @@ export function getJsonNames(text) {
|
|
| 78 |
return names;
|
| 79 |
}
|
| 80 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
function extractJsonPayload(text) {
|
| 82 |
const trimmedText = text.trim();
|
| 83 |
if (!trimmedText) return '[]';
|
|
@@ -108,16 +116,9 @@ const NEXT_ACTIONS = new Map([
|
|
| 108 |
['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'],
|
| 109 |
['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'],
|
| 110 |
['node-modules', 'Run npm install.'],
|
| 111 |
-
[
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
],
|
| 115 |
-
[
|
| 116 |
-
'generated-secrets',
|
| 117 |
-
'Regenerate weak or blank project secrets with npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space --force'
|
| 118 |
-
],
|
| 119 |
-
['remote-variables', 'Configure the required and recommended Space Variables in Hugging Face Settings before syncing secrets.'],
|
| 120 |
-
['remote-secrets', 'Run npm run sync-secret:hf-space after hf auth login succeeds.'],
|
| 121 |
['remote-generation-secret', 'Configure OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS in Space Secrets before real image generation.']
|
| 122 |
]);
|
| 123 |
|
|
@@ -133,8 +134,20 @@ export function buildNextActions(checks) {
|
|
| 133 |
}
|
| 134 |
|
| 135 |
export function validateSpaceId(spaceId) {
|
| 136 |
-
|
| 137 |
-
if (!
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
return undefined;
|
| 139 |
}
|
| 140 |
|
|
|
|
| 1 |
import { spawnSync } from 'node:child_process';
|
| 2 |
+
import { resolve } from 'node:path';
|
|
|
|
| 3 |
import { pathToFileURL } from 'node:url';
|
| 4 |
|
| 5 |
+
export const HF_SPACE_ID = 'misonL/gpt-image-playground-customer';
|
| 6 |
+
export const HF_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
| 7 |
+
const DOCTOR_COMMAND_TIMEOUT_MS = 30_000;
|
| 8 |
|
| 9 |
export function readEnvValue(name) {
|
| 10 |
return process.env[name]?.trim() || undefined;
|
|
|
|
| 35 |
}
|
| 36 |
}
|
| 37 |
|
| 38 |
+
export function runDoctorCommand(command, args = [], options = {}) {
|
| 39 |
const result = spawnSync(command, args, {
|
| 40 |
encoding: 'utf8',
|
| 41 |
+
stdio: ['ignore', 'pipe', 'pipe'],
|
| 42 |
+
timeout: options.timeoutMs || DOCTOR_COMMAND_TIMEOUT_MS
|
| 43 |
});
|
| 44 |
+
if (result.error) {
|
| 45 |
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 46 |
+
return { ok: false, error: output ? `${result.error.message}\n${output}` : result.error.message };
|
| 47 |
+
}
|
| 48 |
if (result.status !== 0) {
|
| 49 |
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 50 |
return { ok: false, error: output || `${command} ${args.join(' ')} failed` };
|
|
|
|
| 52 |
return { ok: true, stdout: result.stdout.trim() };
|
| 53 |
}
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
export function getJsonNames(text) {
|
| 56 |
const parsed = JSON.parse(extractJsonPayload(text));
|
| 57 |
const names = new Set();
|
|
|
|
| 69 |
return names;
|
| 70 |
}
|
| 71 |
|
| 72 |
+
export function getJsonKeyValues(text) {
|
| 73 |
+
const parsed = JSON.parse(extractJsonPayload(text));
|
| 74 |
+
const values = new Map();
|
| 75 |
+
const visit = (value) => {
|
| 76 |
+
if (Array.isArray(value)) {
|
| 77 |
+
for (const item of value) visit(item);
|
| 78 |
+
return;
|
| 79 |
+
}
|
| 80 |
+
if (!value || typeof value !== 'object') return;
|
| 81 |
+
if (typeof value.key === 'string' && typeof value.value === 'string') {
|
| 82 |
+
values.set(value.key, value.value);
|
| 83 |
+
}
|
| 84 |
+
};
|
| 85 |
+
visit(parsed);
|
| 86 |
+
return values;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
function extractJsonPayload(text) {
|
| 90 |
const trimmedText = text.trim();
|
| 91 |
if (!trimmedText) return '[]';
|
|
|
|
| 116 |
['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'],
|
| 117 |
['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'],
|
| 118 |
['node-modules', 'Run npm install.'],
|
| 119 |
+
['remote-variables', 'Configure the required and recommended Space Variables with hf spaces variables add.'],
|
| 120 |
+
['remote-variable-values', 'Set required Space Variable values with hf spaces variables add.'],
|
| 121 |
+
['remote-secrets', 'Configure required Space Secrets with hf spaces secrets add.'],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
['remote-generation-secret', 'Configure OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS in Space Secrets before real image generation.']
|
| 123 |
]);
|
| 124 |
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
export function validateSpaceId(spaceId) {
|
| 137 |
+
const text = spaceId?.trim();
|
| 138 |
+
if (!text) return 'HF Space id is required.';
|
| 139 |
+
const parts = text.split('/');
|
| 140 |
+
if (parts.length < 1 || parts.length > 2 || parts.some((part) => part.length === 0)) {
|
| 141 |
+
return 'HF Space id must use space_name or namespace/space_name format with 1-96 characters per part.';
|
| 142 |
+
}
|
| 143 |
+
for (const part of parts) {
|
| 144 |
+
if (part.length > 96 || !/^[A-Za-z0-9._-]+$/.test(part)) {
|
| 145 |
+
return 'HF Space id must use space_name or namespace/space_name format with 1-96 characters per part.';
|
| 146 |
+
}
|
| 147 |
+
if (part.startsWith('.') || part.includes('..') || part.includes('--') || part.endsWith('.git')) {
|
| 148 |
+
return 'HF Space id parts cannot start with ".", contain ".." or "--", or end with ".git".';
|
| 149 |
+
}
|
| 150 |
+
}
|
| 151 |
return undefined;
|
| 152 |
}
|
| 153 |
|
scripts/hf-space-doctor-utils.test.mjs
CHANGED
|
@@ -8,9 +8,11 @@ import {
|
|
| 8 |
assertSpaceTargetConfig,
|
| 9 |
buildNextActions,
|
| 10 |
classifyRequiredAndRecommendedNames,
|
|
|
|
| 11 |
getJsonNames,
|
| 12 |
isMainModule,
|
| 13 |
readOptionValue,
|
|
|
|
| 14 |
validateSpaceId,
|
| 15 |
validateSpaceUrl
|
| 16 |
} from './hf-space-doctor-utils.mjs';
|
|
@@ -38,6 +40,20 @@ describe('HF Space doctor utilities', () => {
|
|
| 38 |
assert.deepEqual([...names], ['OPENAI_API_KEY']);
|
| 39 |
});
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
it('keeps auth remediation broad enough for network and token failures', () => {
|
| 42 |
const actions = buildNextActions([
|
| 43 |
{
|
|
@@ -76,8 +92,13 @@ describe('HF Space doctor utilities', () => {
|
|
| 76 |
|
| 77 |
it('validates Hugging Face Space target config consistently', () => {
|
| 78 |
assert.equal(validateSpaceId('example/demo'), undefined);
|
|
|
|
| 79 |
assert.equal(validateSpaceUrl('https://example-demo.hf.space'), undefined);
|
| 80 |
-
assert.match(validateSpaceId('
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
assert.match(validateSpaceUrl('https://example.com'), /\.hf\.space/);
|
| 82 |
assert.match(validateSpaceUrl('https://user:pass@example-demo.hf.space'), /plain Space origin/);
|
| 83 |
assert.match(validateSpaceUrl('https://example-demo.hf.space/share/abc'), /plain Space origin/);
|
|
@@ -93,9 +114,15 @@ describe('HF Space doctor utilities', () => {
|
|
| 93 |
});
|
| 94 |
|
| 95 |
it('rejects unknown CLI options and blank inline option values', () => {
|
| 96 |
-
assert.doesNotThrow(() => assertKnownOptions(['--
|
| 97 |
-
assert.
|
| 98 |
-
assert.throws(() => assertKnownOptions(['--unknown'], ['--access-file']), /Unknown option/);
|
| 99 |
assert.throws(() => readOptionValue(['--space-id='], '--space-id'), /requires a value/);
|
| 100 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
});
|
|
|
|
| 8 |
assertSpaceTargetConfig,
|
| 9 |
buildNextActions,
|
| 10 |
classifyRequiredAndRecommendedNames,
|
| 11 |
+
getJsonKeyValues,
|
| 12 |
getJsonNames,
|
| 13 |
isMainModule,
|
| 14 |
readOptionValue,
|
| 15 |
+
runDoctorCommand,
|
| 16 |
validateSpaceId,
|
| 17 |
validateSpaceUrl
|
| 18 |
} from './hf-space-doctor-utils.mjs';
|
|
|
|
| 40 |
assert.deepEqual([...names], ['OPENAI_API_KEY']);
|
| 41 |
});
|
| 42 |
|
| 43 |
+
it('parses hf CLI variable values after hint lines', () => {
|
| 44 |
+
const values = getJsonKeyValues(
|
| 45 |
+
[
|
| 46 |
+
'Hint: Use `hf spaces variables add user/space -e KEY=VALUE` to add variables.',
|
| 47 |
+
'[{"key":"AGENT_STATE_BACKEND","value":"memory"},{"key":"NEXT_PUBLIC_IMAGE_STORAGE_MODE","value":"indexeddb"}]'
|
| 48 |
+
].join('\n')
|
| 49 |
+
);
|
| 50 |
+
|
| 51 |
+
assert.deepEqual([...values], [
|
| 52 |
+
['AGENT_STATE_BACKEND', 'memory'],
|
| 53 |
+
['NEXT_PUBLIC_IMAGE_STORAGE_MODE', 'indexeddb']
|
| 54 |
+
]);
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
it('keeps auth remediation broad enough for network and token failures', () => {
|
| 58 |
const actions = buildNextActions([
|
| 59 |
{
|
|
|
|
| 92 |
|
| 93 |
it('validates Hugging Face Space target config consistently', () => {
|
| 94 |
assert.equal(validateSpaceId('example/demo'), undefined);
|
| 95 |
+
assert.equal(validateSpaceId('demo'), undefined);
|
| 96 |
assert.equal(validateSpaceUrl('https://example-demo.hf.space'), undefined);
|
| 97 |
+
assert.match(validateSpaceId('a/b/c'), /space_name or namespace\/space_name/);
|
| 98 |
+
assert.match(validateSpaceId('.bad/demo'), /cannot start/);
|
| 99 |
+
assert.match(validateSpaceId('bad--name/demo'), /cannot start/);
|
| 100 |
+
assert.match(validateSpaceId('bad.git/demo'), /cannot start/);
|
| 101 |
+
assert.match(validateSpaceId(`${'a'.repeat(97)}/demo`), /1-96 characters/);
|
| 102 |
assert.match(validateSpaceUrl('https://example.com'), /\.hf\.space/);
|
| 103 |
assert.match(validateSpaceUrl('https://user:pass@example-demo.hf.space'), /plain Space origin/);
|
| 104 |
assert.match(validateSpaceUrl('https://example-demo.hf.space/share/abc'), /plain Space origin/);
|
|
|
|
| 114 |
});
|
| 115 |
|
| 116 |
it('rejects unknown CLI options and blank inline option values', () => {
|
| 117 |
+
assert.doesNotThrow(() => assertKnownOptions(['--skip-remote'], ['--skip-remote']));
|
| 118 |
+
assert.throws(() => assertKnownOptions(['--unknown'], ['--skip-remote']), /Unknown option/);
|
|
|
|
| 119 |
assert.throws(() => readOptionValue(['--space-id='], '--space-id'), /requires a value/);
|
| 120 |
});
|
| 121 |
+
|
| 122 |
+
it('times out direct doctor commands instead of hanging', () => {
|
| 123 |
+
const result = runDoctorCommand(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { timeoutMs: 100 });
|
| 124 |
+
|
| 125 |
+
assert.equal(result.ok, false);
|
| 126 |
+
assert.match(result.error, /timed out|ETIMEDOUT|SIGTERM/i);
|
| 127 |
+
});
|
| 128 |
});
|
scripts/keepalive-hf-space.mjs
CHANGED
|
@@ -1,25 +1,11 @@
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
|
|
|
|
| 3 |
import { validateSpaceUrl } from './hf-space-doctor-utils.mjs';
|
| 4 |
|
| 5 |
const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
| 6 |
const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
|
| 7 |
const DEFAULT_TIMEOUT_MS = 30_000;
|
| 8 |
-
const MIN_TIMEOUT_MS = 1_000;
|
| 9 |
-
|
| 10 |
-
function readPositiveIntegerEnv(name, fallback) {
|
| 11 |
-
const rawValue = process.env[name]?.trim();
|
| 12 |
-
if (!rawValue) return fallback;
|
| 13 |
-
if (!/^\d+$/.test(rawValue)) {
|
| 14 |
-
throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
|
| 15 |
-
}
|
| 16 |
-
const value = Number(rawValue);
|
| 17 |
-
if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) {
|
| 18 |
-
throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
|
| 19 |
-
}
|
| 20 |
-
return value;
|
| 21 |
-
}
|
| 22 |
-
|
| 23 |
function normalizeUrl(rawUrl, path) {
|
| 24 |
const urlError = validateSpaceUrl(rawUrl);
|
| 25 |
if (urlError) {
|
|
@@ -51,7 +37,7 @@ async function readJsonResponse(response) {
|
|
| 51 |
async function pingKeepaliveEndpoint() {
|
| 52 |
const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL;
|
| 53 |
const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH;
|
| 54 |
-
const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
|
| 55 |
const expectedPasswordRequired = readExpectedPasswordRequired();
|
| 56 |
const url = normalizeUrl(spaceUrl, path);
|
| 57 |
const controller = new AbortController();
|
|
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
|
| 3 |
+
import { readPositiveIntegerEnv } from './env-utils.mjs';
|
| 4 |
import { validateSpaceUrl } from './hf-space-doctor-utils.mjs';
|
| 5 |
|
| 6 |
const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
| 7 |
const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
|
| 8 |
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
function normalizeUrl(rawUrl, path) {
|
| 10 |
const urlError = validateSpaceUrl(rawUrl);
|
| 11 |
if (urlError) {
|
|
|
|
| 37 |
async function pingKeepaliveEndpoint() {
|
| 38 |
const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL;
|
| 39 |
const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH;
|
| 40 |
+
const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1_000);
|
| 41 |
const expectedPasswordRequired = readExpectedPasswordRequired();
|
| 42 |
const url = normalizeUrl(spaceUrl, path);
|
| 43 |
const controller = new AbortController();
|
scripts/local-image-upstream-fixture.mjs
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import http from 'node:http';
|
| 4 |
+
import { pathToFileURL } from 'node:url';
|
| 5 |
+
|
| 6 |
+
export const FIXTURE_IMAGE_BASE64 =
|
| 7 |
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
| 8 |
+
export const MAX_JSON_BODY_BYTES = 1024 * 1024;
|
| 9 |
+
|
| 10 |
+
class PayloadTooLargeError extends Error {
|
| 11 |
+
constructor() {
|
| 12 |
+
super('Request body too large');
|
| 13 |
+
this.name = 'PayloadTooLargeError';
|
| 14 |
+
this.status = 413;
|
| 15 |
+
}
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
function readJsonBody(request) {
|
| 19 |
+
return new Promise((resolve, reject) => {
|
| 20 |
+
let raw = '';
|
| 21 |
+
let byteLength = 0;
|
| 22 |
+
let tooLarge = false;
|
| 23 |
+
let settled = false;
|
| 24 |
+
const cleanup = () => {
|
| 25 |
+
request.off('data', onData);
|
| 26 |
+
request.off('end', onEnd);
|
| 27 |
+
request.off('error', onError);
|
| 28 |
+
};
|
| 29 |
+
const fail = (error) => {
|
| 30 |
+
if (settled) return;
|
| 31 |
+
settled = true;
|
| 32 |
+
cleanup();
|
| 33 |
+
reject(error);
|
| 34 |
+
};
|
| 35 |
+
const finish = (value) => {
|
| 36 |
+
if (settled) return;
|
| 37 |
+
settled = true;
|
| 38 |
+
cleanup();
|
| 39 |
+
resolve(value);
|
| 40 |
+
};
|
| 41 |
+
const onData = (chunk) => {
|
| 42 |
+
byteLength += Buffer.byteLength(chunk, 'utf8');
|
| 43 |
+
if (byteLength > MAX_JSON_BODY_BYTES) {
|
| 44 |
+
tooLarge = true;
|
| 45 |
+
return;
|
| 46 |
+
}
|
| 47 |
+
if (tooLarge) return;
|
| 48 |
+
raw += chunk;
|
| 49 |
+
};
|
| 50 |
+
const onEnd = () => {
|
| 51 |
+
if (tooLarge) {
|
| 52 |
+
fail(new PayloadTooLargeError());
|
| 53 |
+
return;
|
| 54 |
+
}
|
| 55 |
+
if (!raw.trim()) {
|
| 56 |
+
finish({});
|
| 57 |
+
return;
|
| 58 |
+
}
|
| 59 |
+
try {
|
| 60 |
+
finish(JSON.parse(raw));
|
| 61 |
+
} catch (error) {
|
| 62 |
+
fail(error);
|
| 63 |
+
}
|
| 64 |
+
};
|
| 65 |
+
const onError = (error) => fail(error);
|
| 66 |
+
request.setEncoding('utf8');
|
| 67 |
+
request.on('data', onData);
|
| 68 |
+
request.on('end', onEnd);
|
| 69 |
+
request.on('error', onError);
|
| 70 |
+
});
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function sendJson(response, status, body) {
|
| 74 |
+
response.writeHead(status, { 'Content-Type': 'application/json' });
|
| 75 |
+
response.end(JSON.stringify(body));
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function writeSse(response, event, payload) {
|
| 79 |
+
if (event) response.write(`event: ${event}\n`);
|
| 80 |
+
response.write(`data: ${JSON.stringify(payload)}\n\n`);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function sendImagesStream(response) {
|
| 84 |
+
response.writeHead(200, {
|
| 85 |
+
'Content-Type': 'text/event-stream',
|
| 86 |
+
'Cache-Control': 'no-cache',
|
| 87 |
+
Connection: 'keep-alive'
|
| 88 |
+
});
|
| 89 |
+
response.write(': keepalive\n\n');
|
| 90 |
+
writeSse(response, 'image_generation.partial_image', {
|
| 91 |
+
type: 'image_generation.partial_image',
|
| 92 |
+
partial_image_index: 0,
|
| 93 |
+
b64_json: FIXTURE_IMAGE_BASE64
|
| 94 |
+
});
|
| 95 |
+
writeSse(response, 'image_generation.completed', {
|
| 96 |
+
type: 'image_generation.completed',
|
| 97 |
+
b64_json: FIXTURE_IMAGE_BASE64,
|
| 98 |
+
output_format: 'png',
|
| 99 |
+
size: '1024x1024',
|
| 100 |
+
quality: 'low'
|
| 101 |
+
});
|
| 102 |
+
response.end('data: [DONE]\n\n');
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function sendResponsesStream(response) {
|
| 106 |
+
response.writeHead(200, {
|
| 107 |
+
'Content-Type': 'text/event-stream',
|
| 108 |
+
'Cache-Control': 'no-cache',
|
| 109 |
+
Connection: 'keep-alive'
|
| 110 |
+
});
|
| 111 |
+
writeSse(response, 'response.image_generation_call.partial_image', {
|
| 112 |
+
type: 'response.image_generation_call.partial_image',
|
| 113 |
+
partial_image_b64: FIXTURE_IMAGE_BASE64,
|
| 114 |
+
partial_image_index: 0
|
| 115 |
+
});
|
| 116 |
+
writeSse(response, 'response.output_item.done', {
|
| 117 |
+
type: 'response.output_item.done',
|
| 118 |
+
item: {
|
| 119 |
+
id: 'ig_fixture',
|
| 120 |
+
type: 'image_generation_call',
|
| 121 |
+
status: 'completed',
|
| 122 |
+
result: FIXTURE_IMAGE_BASE64
|
| 123 |
+
}
|
| 124 |
+
});
|
| 125 |
+
writeSse(response, 'response.completed', {
|
| 126 |
+
type: 'response.completed',
|
| 127 |
+
response: {
|
| 128 |
+
output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }]
|
| 129 |
+
}
|
| 130 |
+
});
|
| 131 |
+
response.end('data: [DONE]\n\n');
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
function imagesResponse(body) {
|
| 135 |
+
const created = Math.floor(Date.now() / 1000);
|
| 136 |
+
return {
|
| 137 |
+
created,
|
| 138 |
+
data: [{ b64_json: FIXTURE_IMAGE_BASE64 }],
|
| 139 |
+
usage: {
|
| 140 |
+
input_tokens: 1,
|
| 141 |
+
output_tokens: 1,
|
| 142 |
+
total_tokens: 2
|
| 143 |
+
},
|
| 144 |
+
fixture_request: {
|
| 145 |
+
model: typeof body.model === 'string' ? body.model : undefined,
|
| 146 |
+
stream: body.stream === true
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function responsesJsonResponse(body) {
|
| 152 |
+
return {
|
| 153 |
+
id: 'resp_fixture',
|
| 154 |
+
object: 'response',
|
| 155 |
+
model: typeof body.model === 'string' ? body.model : 'gpt-5.4',
|
| 156 |
+
output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }],
|
| 157 |
+
usage: {
|
| 158 |
+
input_tokens: 1,
|
| 159 |
+
output_tokens: 1,
|
| 160 |
+
total_tokens: 2
|
| 161 |
+
}
|
| 162 |
+
};
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
function modelsResponse() {
|
| 166 |
+
return {
|
| 167 |
+
object: 'list',
|
| 168 |
+
data: [
|
| 169 |
+
{ id: 'gpt-image-2', object: 'model', owned_by: 'fixture' },
|
| 170 |
+
{ id: 'gpt-5.4', object: 'model', owned_by: 'fixture' }
|
| 171 |
+
]
|
| 172 |
+
};
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
async function handleRequest(request, response) {
|
| 176 |
+
const url = new URL(request.url || '/', 'http://fixture.local');
|
| 177 |
+
if (request.method === 'GET' && url.pathname === '/health') {
|
| 178 |
+
sendJson(response, 200, { ok: true });
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
if (request.method === 'GET' && url.pathname === '/v1/models') {
|
| 182 |
+
sendJson(response, 200, modelsResponse());
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
|
| 186 |
+
const body = await readJsonBody(request);
|
| 187 |
+
if (body.stream === true) {
|
| 188 |
+
sendImagesStream(response);
|
| 189 |
+
return;
|
| 190 |
+
}
|
| 191 |
+
sendJson(response, 200, imagesResponse(body));
|
| 192 |
+
return;
|
| 193 |
+
}
|
| 194 |
+
if (request.method === 'POST' && url.pathname === '/v1/responses') {
|
| 195 |
+
const body = await readJsonBody(request);
|
| 196 |
+
if (body.stream === true) {
|
| 197 |
+
sendResponsesStream(response);
|
| 198 |
+
return;
|
| 199 |
+
}
|
| 200 |
+
sendJson(response, 200, responsesJsonResponse(body));
|
| 201 |
+
return;
|
| 202 |
+
}
|
| 203 |
+
sendJson(response, 404, { error: { message: `No fixture route for ${request.method} ${url.pathname}` } });
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
export function createFixtureServer() {
|
| 207 |
+
return http.createServer((request, response) => {
|
| 208 |
+
handleRequest(request, response).catch((error) => {
|
| 209 |
+
const status = error instanceof PayloadTooLargeError ? error.status : 500;
|
| 210 |
+
sendJson(response, status, { error: { message: error instanceof Error ? error.message : String(error) } });
|
| 211 |
+
});
|
| 212 |
+
});
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
export function startFixtureServer(port = Number(process.env.PORT || 19080), host = process.env.HOST || '127.0.0.1') {
|
| 216 |
+
const server = createFixtureServer();
|
| 217 |
+
server.listen(port, host, () => {
|
| 218 |
+
const address = server.address();
|
| 219 |
+
const resolvedPort = typeof address === 'object' && address ? address.port : port;
|
| 220 |
+
console.log(`local image upstream fixture listening on http://${host}:${resolvedPort}`);
|
| 221 |
+
});
|
| 222 |
+
return server;
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
| 226 |
+
startFixtureServer();
|
| 227 |
+
}
|
scripts/local-image-upstream-fixture.test.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { describe, it } from 'node:test';
|
| 3 |
+
|
| 4 |
+
import { FIXTURE_IMAGE_BASE64, MAX_JSON_BODY_BYTES, createFixtureServer } from './local-image-upstream-fixture.mjs';
|
| 5 |
+
|
| 6 |
+
describe('local image upstream fixture', () => {
|
| 7 |
+
it('serves Images API JSON responses', async () => {
|
| 8 |
+
const fixture = await startFixture();
|
| 9 |
+
try {
|
| 10 |
+
const response = await fetch(`${fixture.baseUrl}/v1/images/generations`, {
|
| 11 |
+
method: 'POST',
|
| 12 |
+
headers: { 'Content-Type': 'application/json' },
|
| 13 |
+
body: JSON.stringify({ model: 'gpt-image-2', prompt: 'fixture json smoke', stream: false })
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
assert.equal(response.status, 200);
|
| 17 |
+
assert.match(response.headers.get('content-type') || '', /^application\/json\b/);
|
| 18 |
+
const body = await response.json();
|
| 19 |
+
assert.equal(body.data[0].b64_json, FIXTURE_IMAGE_BASE64);
|
| 20 |
+
assert.equal(body.fixture_request.model, 'gpt-image-2');
|
| 21 |
+
assert.equal(body.fixture_request.stream, false);
|
| 22 |
+
} finally {
|
| 23 |
+
await fixture.close();
|
| 24 |
+
}
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
it('serves Images API SSE responses', async () => {
|
| 28 |
+
const fixture = await startFixture();
|
| 29 |
+
try {
|
| 30 |
+
const response = await fetch(`${fixture.baseUrl}/v1/images/generations`, {
|
| 31 |
+
method: 'POST',
|
| 32 |
+
headers: { 'Content-Type': 'application/json' },
|
| 33 |
+
body: JSON.stringify({ model: 'gpt-image-2', prompt: 'fixture sse smoke', stream: true })
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
assert.equal(response.status, 200);
|
| 37 |
+
assert.match(response.headers.get('content-type') || '', /^text\/event-stream\b/);
|
| 38 |
+
const events = readSseEvents(await response.text());
|
| 39 |
+
assert.equal(events[0].comment, 'keepalive');
|
| 40 |
+
assert.deepEqual(
|
| 41 |
+
events.filter((event) => event.event).map((event) => event.event),
|
| 42 |
+
['image_generation.partial_image', 'image_generation.completed']
|
| 43 |
+
);
|
| 44 |
+
assert.equal(events[1].data.type, 'image_generation.partial_image');
|
| 45 |
+
assert.equal(events[1].data.b64_json, FIXTURE_IMAGE_BASE64);
|
| 46 |
+
assert.equal(events[2].data.type, 'image_generation.completed');
|
| 47 |
+
assert.equal(events[2].data.b64_json, FIXTURE_IMAGE_BASE64);
|
| 48 |
+
assert.equal(events[3].done, true);
|
| 49 |
+
} finally {
|
| 50 |
+
await fixture.close();
|
| 51 |
+
}
|
| 52 |
+
});
|
| 53 |
+
|
| 54 |
+
it('serves Responses API JSON image outputs', async () => {
|
| 55 |
+
const fixture = await startFixture();
|
| 56 |
+
try {
|
| 57 |
+
const response = await fetch(`${fixture.baseUrl}/v1/responses`, {
|
| 58 |
+
method: 'POST',
|
| 59 |
+
headers: { 'Content-Type': 'application/json' },
|
| 60 |
+
body: JSON.stringify({
|
| 61 |
+
model: 'gpt-5.4',
|
| 62 |
+
input: 'fixture responses json smoke',
|
| 63 |
+
stream: false,
|
| 64 |
+
tools: [{ type: 'image_generation' }],
|
| 65 |
+
tool_choice: { type: 'image_generation' }
|
| 66 |
+
})
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
assert.equal(response.status, 200);
|
| 70 |
+
assert.match(response.headers.get('content-type') || '', /^application\/json\b/);
|
| 71 |
+
const body = await response.json();
|
| 72 |
+
assert.equal(body.id, 'resp_fixture');
|
| 73 |
+
assert.equal(body.output[0].type, 'image_generation_call');
|
| 74 |
+
assert.equal(body.output[0].result, FIXTURE_IMAGE_BASE64);
|
| 75 |
+
} finally {
|
| 76 |
+
await fixture.close();
|
| 77 |
+
}
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
it('serves Responses API SSE image events', async () => {
|
| 81 |
+
const fixture = await startFixture();
|
| 82 |
+
try {
|
| 83 |
+
const response = await fetch(`${fixture.baseUrl}/v1/responses`, {
|
| 84 |
+
method: 'POST',
|
| 85 |
+
headers: { 'Content-Type': 'application/json' },
|
| 86 |
+
body: JSON.stringify({
|
| 87 |
+
model: 'gpt-5.4',
|
| 88 |
+
input: 'fixture responses sse smoke',
|
| 89 |
+
stream: true,
|
| 90 |
+
tools: [{ type: 'image_generation' }],
|
| 91 |
+
tool_choice: { type: 'image_generation' }
|
| 92 |
+
})
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
assert.equal(response.status, 200);
|
| 96 |
+
assert.match(response.headers.get('content-type') || '', /^text\/event-stream\b/);
|
| 97 |
+
const events = readSseEvents(await response.text());
|
| 98 |
+
assert.deepEqual(
|
| 99 |
+
events.filter((event) => event.event).map((event) => event.event),
|
| 100 |
+
[
|
| 101 |
+
'response.image_generation_call.partial_image',
|
| 102 |
+
'response.output_item.done',
|
| 103 |
+
'response.completed'
|
| 104 |
+
]
|
| 105 |
+
);
|
| 106 |
+
assert.equal(events[0].data.partial_image_b64, FIXTURE_IMAGE_BASE64);
|
| 107 |
+
assert.equal(events[1].data.item.type, 'image_generation_call');
|
| 108 |
+
assert.equal(events[1].data.item.result, FIXTURE_IMAGE_BASE64);
|
| 109 |
+
assert.equal(events[2].data.response.output[0].result, FIXTURE_IMAGE_BASE64);
|
| 110 |
+
assert.equal(events[3].done, true);
|
| 111 |
+
} finally {
|
| 112 |
+
await fixture.close();
|
| 113 |
+
}
|
| 114 |
+
});
|
| 115 |
+
|
| 116 |
+
it('serves model discovery and health checks', async () => {
|
| 117 |
+
const fixture = await startFixture();
|
| 118 |
+
try {
|
| 119 |
+
const healthResponse = await fetch(`${fixture.baseUrl}/health`);
|
| 120 |
+
const modelsResponse = await fetch(`${fixture.baseUrl}/v1/models`);
|
| 121 |
+
|
| 122 |
+
assert.equal(healthResponse.status, 200);
|
| 123 |
+
assert.deepEqual(await healthResponse.json(), { ok: true });
|
| 124 |
+
assert.equal(modelsResponse.status, 200);
|
| 125 |
+
const models = await modelsResponse.json();
|
| 126 |
+
assert.deepEqual(
|
| 127 |
+
models.data.map((model) => model.id),
|
| 128 |
+
['gpt-image-2', 'gpt-5.4']
|
| 129 |
+
);
|
| 130 |
+
} finally {
|
| 131 |
+
await fixture.close();
|
| 132 |
+
}
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
it('rejects oversized JSON request bodies', async () => {
|
| 136 |
+
const fixture = await startFixture();
|
| 137 |
+
try {
|
| 138 |
+
const response = await fetch(`${fixture.baseUrl}/v1/images/generations`, {
|
| 139 |
+
method: 'POST',
|
| 140 |
+
headers: { 'Content-Type': 'application/json' },
|
| 141 |
+
body: JSON.stringify({
|
| 142 |
+
model: 'gpt-image-2',
|
| 143 |
+
prompt: 'x'.repeat(MAX_JSON_BODY_BYTES)
|
| 144 |
+
})
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
assert.equal(response.status, 413);
|
| 148 |
+
const body = await response.json();
|
| 149 |
+
assert.match(body.error.message, /too large/);
|
| 150 |
+
} finally {
|
| 151 |
+
await fixture.close();
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
});
|
| 155 |
+
|
| 156 |
+
async function startFixture() {
|
| 157 |
+
const server = createFixtureServer();
|
| 158 |
+
await new Promise((resolve, reject) => {
|
| 159 |
+
server.once('error', reject);
|
| 160 |
+
server.listen(0, '127.0.0.1', resolve);
|
| 161 |
+
});
|
| 162 |
+
const address = server.address();
|
| 163 |
+
assert.ok(address && typeof address === 'object');
|
| 164 |
+
return {
|
| 165 |
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
| 166 |
+
close: () => closeServer(server)
|
| 167 |
+
};
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function closeServer(server) {
|
| 171 |
+
return new Promise((resolve, reject) => {
|
| 172 |
+
server.close((error) => {
|
| 173 |
+
if (error) reject(error);
|
| 174 |
+
else resolve();
|
| 175 |
+
});
|
| 176 |
+
});
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
function readSseEvents(raw) {
|
| 180 |
+
return raw
|
| 181 |
+
.trim()
|
| 182 |
+
.split(/\n\n+/)
|
| 183 |
+
.map((chunk) => readSseEvent(chunk))
|
| 184 |
+
.filter(Boolean);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function readSseEvent(chunk) {
|
| 188 |
+
const lines = chunk.split(/\n/);
|
| 189 |
+
const comment = lines.find((line) => line.startsWith(': '));
|
| 190 |
+
const event = lines.find((line) => line.startsWith('event: '));
|
| 191 |
+
const data = lines.find((line) => line.startsWith('data: '));
|
| 192 |
+
if (comment) return { comment: comment.slice(2) };
|
| 193 |
+
if (!data) return undefined;
|
| 194 |
+
if (data === 'data: [DONE]') return { done: true };
|
| 195 |
+
return {
|
| 196 |
+
event: event ? event.slice('event: '.length) : undefined,
|
| 197 |
+
data: JSON.parse(data.slice('data: '.length))
|
| 198 |
+
};
|
| 199 |
+
}
|
scripts/smoke-hf-space-memory.mjs
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
import { spawnSync } from 'node:child_process';
|
| 3 |
|
|
|
|
|
|
|
| 4 |
const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke';
|
| 5 |
const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke';
|
| 6 |
const hostPort = process.env.HF_SPACE_SMOKE_PORT || '4785';
|
| 7 |
const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token';
|
| 8 |
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
|
|
|
| 9 |
|
| 10 |
function run(command, args, options = {}) {
|
| 11 |
const result = spawnSync(command, args, {
|
|
@@ -36,7 +39,7 @@ async function fetchJson(path, options = {}) {
|
|
| 36 |
}
|
| 37 |
|
| 38 |
async function waitForReady() {
|
| 39 |
-
const deadline = Date.now() +
|
| 40 |
let lastError;
|
| 41 |
while (Date.now() < deadline) {
|
| 42 |
try {
|
|
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
import { spawnSync } from 'node:child_process';
|
| 3 |
|
| 4 |
+
import { readPositiveIntegerEnv } from './env-utils.mjs';
|
| 5 |
+
|
| 6 |
const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke';
|
| 7 |
const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke';
|
| 8 |
const hostPort = process.env.HF_SPACE_SMOKE_PORT || '4785';
|
| 9 |
const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token';
|
| 10 |
const baseUrl = `http://127.0.0.1:${hostPort}`;
|
| 11 |
+
const readyTimeoutMs = readPositiveIntegerEnv('HF_SPACE_SMOKE_READY_TIMEOUT_MS', 45_000);
|
| 12 |
|
| 13 |
function run(command, args, options = {}) {
|
| 14 |
const result = spawnSync(command, args, {
|
|
|
|
| 39 |
}
|
| 40 |
|
| 41 |
async function waitForReady() {
|
| 42 |
+
const deadline = Date.now() + readyTimeoutMs;
|
| 43 |
let lastError;
|
| 44 |
while (Date.now() < deadline) {
|
| 45 |
try {
|
scripts/smoke-hf-space-memory.test.mjs
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawnSync } from 'node:child_process';
|
| 3 |
+
import { join } from 'node:path';
|
| 4 |
+
import { fileURLToPath } from 'node:url';
|
| 5 |
+
import { describe, it } from 'node:test';
|
| 6 |
+
|
| 7 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 8 |
+
const scriptPath = join(repoRoot, 'scripts/smoke-hf-space-memory.mjs');
|
| 9 |
+
|
| 10 |
+
describe('HF Space memory smoke script validation', () => {
|
| 11 |
+
it('rejects invalid ready timeout values before Docker access', () => {
|
| 12 |
+
const result = spawnSync(process.execPath, [scriptPath], {
|
| 13 |
+
cwd: repoRoot,
|
| 14 |
+
encoding: 'utf8',
|
| 15 |
+
env: { ...process.env, HF_SPACE_SMOKE_READY_TIMEOUT_MS: '45s' }
|
| 16 |
+
});
|
| 17 |
+
|
| 18 |
+
assert.equal(result.status, 1);
|
| 19 |
+
assert.match(result.stderr, /HF_SPACE_SMOKE_READY_TIMEOUT_MS/);
|
| 20 |
+
assert.match(result.stderr, /positive integer/);
|
| 21 |
+
assert.equal(result.stdout.trim(), '');
|
| 22 |
+
});
|
| 23 |
+
});
|
scripts/smoke-image-upstream-compat.mjs
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import http from 'node:http';
|
| 4 |
+
const PNG_BASE64 =
|
| 5 |
+
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
| 6 |
+
const originalEnv = { ...process.env };
|
| 7 |
+
function restoreProcessEnv() {
|
| 8 |
+
for (const key of Object.keys(process.env)) {
|
| 9 |
+
if (!(key in originalEnv)) delete process.env[key];
|
| 10 |
+
}
|
| 11 |
+
for (const [key, value] of Object.entries(originalEnv)) process.env[key] = value;
|
| 12 |
+
}
|
| 13 |
+
function configureRouteEnv() {
|
| 14 |
+
for (const key of [
|
| 15 |
+
'APP_PASSWORD',
|
| 16 |
+
'OPENAI_API_KEY',
|
| 17 |
+
'OPENAI_API_BASE_URL',
|
| 18 |
+
'OPENAI_CHANNEL_1_API_KEYS',
|
| 19 |
+
'OPENAI_CHANNEL_1_BASE_URL',
|
| 20 |
+
'IMAGE_GENERATION_BACKEND',
|
| 21 |
+
'IMAGE_STREAMING_STRATEGY'
|
| 22 |
+
]) {
|
| 23 |
+
delete process.env[key];
|
| 24 |
+
}
|
| 25 |
+
process.env.APP_LOG_LEVEL = 'warn';
|
| 26 |
+
process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'indexeddb';
|
| 27 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 28 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 29 |
+
}
|
| 30 |
+
function imageRequest(input) {
|
| 31 |
+
const formData = new FormData();
|
| 32 |
+
for (const [key, value] of Object.entries({
|
| 33 |
+
mode: 'generate',
|
| 34 |
+
prompt: input.prompt || 'local image upstream compatibility smoke',
|
| 35 |
+
model: 'gpt-image-2',
|
| 36 |
+
n: '1',
|
| 37 |
+
size: '1024x1024',
|
| 38 |
+
quality: 'high',
|
| 39 |
+
output_format: 'png',
|
| 40 |
+
apiBaseUrl: input.apiBaseUrl,
|
| 41 |
+
apiKey: 'local-smoke-key',
|
| 42 |
+
clientRequestId: input.clientRequestId || 'image-upstream-compat-smoke'
|
| 43 |
+
})) {
|
| 44 |
+
formData.append(key, value);
|
| 45 |
+
}
|
| 46 |
+
if (input.imageBackend) formData.append('imageBackend', input.imageBackend);
|
| 47 |
+
if (input.imageStreamingStrategy) formData.append('imageStreamingStrategy', input.imageStreamingStrategy);
|
| 48 |
+
if (input.responsesModel) formData.append('responsesModel', input.responsesModel);
|
| 49 |
+
if (input.stream) {
|
| 50 |
+
formData.append('stream', 'true');
|
| 51 |
+
formData.append('partial_images', '2');
|
| 52 |
+
}
|
| 53 |
+
return new Request('http://localhost/api/images', { method: 'POST', body: formData });
|
| 54 |
+
}
|
| 55 |
+
async function readBody(request) {
|
| 56 |
+
const chunks = [];
|
| 57 |
+
for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
| 58 |
+
return Buffer.concat(chunks).toString('utf8');
|
| 59 |
+
}
|
| 60 |
+
function sendJson(response, status, payload) {
|
| 61 |
+
response.writeHead(status, { 'Content-Type': 'application/json' });
|
| 62 |
+
response.end(JSON.stringify(payload));
|
| 63 |
+
}
|
| 64 |
+
function sendSse(response, events) {
|
| 65 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 66 |
+
for (const event of events) {
|
| 67 |
+
if (event.comment) response.write(`: ${event.comment}\n\n`);
|
| 68 |
+
if (event.event) response.write(`event: ${event.event}\n`);
|
| 69 |
+
if ('data' in event) response.write(`data: ${JSON.stringify(event.data)}\n\n`);
|
| 70 |
+
}
|
| 71 |
+
response.write('data: [DONE]\n\n');
|
| 72 |
+
response.end();
|
| 73 |
+
}
|
| 74 |
+
function costLogResponse() {
|
| 75 |
+
const now = Math.floor(Date.now() / 1000);
|
| 76 |
+
return {
|
| 77 |
+
success: true,
|
| 78 |
+
data: [{ id: now, type: 2, model_name: 'gpt-image-2', quota: 3750, created_at: now, request_id: 'local-smoke' }]
|
| 79 |
+
};
|
| 80 |
+
}
|
| 81 |
+
async function startMockUpstream(handler) {
|
| 82 |
+
const calls = [];
|
| 83 |
+
const server = http.createServer(async (request, response) => {
|
| 84 |
+
try {
|
| 85 |
+
if (request.method === 'GET' && request.url === '/api/log/token') {
|
| 86 |
+
sendJson(response, 200, costLogResponse());
|
| 87 |
+
return;
|
| 88 |
+
}
|
| 89 |
+
const body = await readBody(request);
|
| 90 |
+
calls.push({ method: request.method, url: request.url, body });
|
| 91 |
+
await handler({ body, request, response });
|
| 92 |
+
} catch (error) {
|
| 93 |
+
sendJson(response, 500, { error: { message: error instanceof Error ? error.message : String(error) } });
|
| 94 |
+
}
|
| 95 |
+
});
|
| 96 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 97 |
+
const address = server.address();
|
| 98 |
+
assert.ok(address && typeof address === 'object');
|
| 99 |
+
return {
|
| 100 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 101 |
+
calls,
|
| 102 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 103 |
+
};
|
| 104 |
+
}
|
| 105 |
+
function assertPath(request, expectedPath) {
|
| 106 |
+
assert.equal(request.method, 'POST');
|
| 107 |
+
assert.equal(request.url?.endsWith(expectedPath), true);
|
| 108 |
+
}
|
| 109 |
+
async function readJsonImage(response) {
|
| 110 |
+
assert.equal(response.status, 200);
|
| 111 |
+
assert.match(response.headers.get('content-type') || '', /application\/json/);
|
| 112 |
+
const body = await response.json();
|
| 113 |
+
assert.equal(Array.isArray(body.images), true);
|
| 114 |
+
assert.equal(body.images.length, 1);
|
| 115 |
+
assert.equal(body.images[0].b64_json, PNG_BASE64);
|
| 116 |
+
return body;
|
| 117 |
+
}
|
| 118 |
+
async function readSseImages(response, expectedTypes) {
|
| 119 |
+
assert.equal(response.status, 200);
|
| 120 |
+
assert.equal(response.headers.get('content-type'), 'text/event-stream');
|
| 121 |
+
const events = (await response.text())
|
| 122 |
+
.split('\n\n')
|
| 123 |
+
.map((part) => part.trim())
|
| 124 |
+
.filter((part) => part.startsWith('data: ') && part !== 'data: [DONE]')
|
| 125 |
+
.map((part) => JSON.parse(part.slice('data: '.length)));
|
| 126 |
+
assert.deepEqual(
|
| 127 |
+
events.map((event) => event.type),
|
| 128 |
+
expectedTypes
|
| 129 |
+
);
|
| 130 |
+
const done = events.at(-1);
|
| 131 |
+
assert.equal(done?.type, 'done');
|
| 132 |
+
assert.equal(done.images.length, 1);
|
| 133 |
+
assert.equal(done.images[0].b64_json, PNG_BASE64);
|
| 134 |
+
return events;
|
| 135 |
+
}
|
| 136 |
+
function completedImageEvent(type, data) {
|
| 137 |
+
return { event: type, data: { type, ...data } };
|
| 138 |
+
}
|
| 139 |
+
function responsesOutputItemDone(result) {
|
| 140 |
+
return {
|
| 141 |
+
event: 'response.output_item.done',
|
| 142 |
+
data: { type: 'response.output_item.done', item: { type: 'image_generation_call', status: 'completed', result } }
|
| 143 |
+
};
|
| 144 |
+
}
|
| 145 |
+
const cases = [
|
| 146 |
+
{
|
| 147 |
+
name: 'original new-api Images API JSON',
|
| 148 |
+
form: { stream: false, clientRequestId: 'smoke-images-json' },
|
| 149 |
+
handle: ({ body, request, response }) => {
|
| 150 |
+
assertPath(request, '/v1/images/generations');
|
| 151 |
+
assert.equal(JSON.parse(body).stream, false);
|
| 152 |
+
sendJson(response, 200, {
|
| 153 |
+
created: Math.floor(Date.now() / 1000),
|
| 154 |
+
data: [{ b64_json: PNG_BASE64 }],
|
| 155 |
+
usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }
|
| 156 |
+
});
|
| 157 |
+
},
|
| 158 |
+
verify: async (response) => {
|
| 159 |
+
await readJsonImage(response);
|
| 160 |
+
}
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
name: 'sub2api Images API JSON',
|
| 164 |
+
form: { stream: false, clientRequestId: 'smoke-sub2api-images-json' },
|
| 165 |
+
handle: ({ body, request, response }) => {
|
| 166 |
+
assertPath(request, '/v1/images/generations');
|
| 167 |
+
const requestJson = JSON.parse(body);
|
| 168 |
+
assert.equal(requestJson.stream, false);
|
| 169 |
+
assert.equal(Object.hasOwn(requestJson, 'partial_images'), false);
|
| 170 |
+
sendJson(response, 200, {
|
| 171 |
+
created: Math.floor(Date.now() / 1000),
|
| 172 |
+
data: [{ b64_json: PNG_BASE64 }],
|
| 173 |
+
usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }
|
| 174 |
+
});
|
| 175 |
+
},
|
| 176 |
+
verify: async (response) => {
|
| 177 |
+
await readJsonImage(response);
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
{
|
| 181 |
+
name: 'gaoren new-api Images SSE keepalive',
|
| 182 |
+
form: { stream: true, imageStreamingStrategy: 'newapi-keepalive-sse', clientRequestId: 'smoke-gaoren-keepalive' },
|
| 183 |
+
handle: ({ body, request, response }) => {
|
| 184 |
+
assertPath(request, '/v1/images/generations');
|
| 185 |
+
const requestJson = JSON.parse(body);
|
| 186 |
+
assert.equal(requestJson.stream, true);
|
| 187 |
+
assert.equal(requestJson.partial_images, 2);
|
| 188 |
+
sendSse(response, [
|
| 189 |
+
{ comment: 'keepalive' },
|
| 190 |
+
completedImageEvent('image_generation.partial_image', {
|
| 191 |
+
b64_json: 'gaoren-partial-base64',
|
| 192 |
+
partial_image_index: 0
|
| 193 |
+
}),
|
| 194 |
+
completedImageEvent('image_generation.completed', { b64_json: PNG_BASE64 })
|
| 195 |
+
]);
|
| 196 |
+
},
|
| 197 |
+
verify: async (response) => {
|
| 198 |
+
const events = await readSseImages(response, ['partial_image', 'completed', 'done']);
|
| 199 |
+
assert.equal(events[0].b64_json, 'gaoren-partial-base64');
|
| 200 |
+
}
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
name: 'gaoren new-api JSON-as-SSE completed event',
|
| 204 |
+
form: { stream: true, imageStreamingStrategy: 'newapi-keepalive-sse', clientRequestId: 'smoke-gaoren-json-as-sse' },
|
| 205 |
+
handle: ({ request, response }) => {
|
| 206 |
+
assertPath(request, '/v1/images/generations');
|
| 207 |
+
sendSse(response, [completedImageEvent('image_generation.completed', { data: [{ b64_json: PNG_BASE64 }] })]);
|
| 208 |
+
},
|
| 209 |
+
verify: async (response) => {
|
| 210 |
+
await readSseImages(response, ['completed', 'done']);
|
| 211 |
+
}
|
| 212 |
+
},
|
| 213 |
+
{
|
| 214 |
+
name: 'sub2api Images SSE',
|
| 215 |
+
form: { stream: true, imageStreamingStrategy: 'openai-sse', clientRequestId: 'smoke-sub2api-images-sse' },
|
| 216 |
+
handle: ({ request, response }) => {
|
| 217 |
+
assertPath(request, '/v1/images/generations');
|
| 218 |
+
sendSse(response, [
|
| 219 |
+
{ event: 'image.generation.chunk', data: { b64_json: 'sub2api-partial-base64' } },
|
| 220 |
+
{ event: 'image.generation.result', data: { data: [{ b64_json: PNG_BASE64 }] } }
|
| 221 |
+
]);
|
| 222 |
+
},
|
| 223 |
+
verify: async (response) => {
|
| 224 |
+
const events = await readSseImages(response, ['partial_image', 'completed', 'done']);
|
| 225 |
+
assert.equal(events[0].b64_json, 'sub2api-partial-base64');
|
| 226 |
+
}
|
| 227 |
+
},
|
| 228 |
+
{
|
| 229 |
+
name: 'sub2api Responses image_generation bridge JSON',
|
| 230 |
+
form: {
|
| 231 |
+
stream: false,
|
| 232 |
+
imageBackend: 'responses-image-generation',
|
| 233 |
+
responsesModel: 'gpt-5.4',
|
| 234 |
+
clientRequestId: 'smoke-responses-bridge-json'
|
| 235 |
+
},
|
| 236 |
+
handle: ({ body, request, response }) => {
|
| 237 |
+
assertPath(request, '/v1/responses');
|
| 238 |
+
const requestJson = JSON.parse(body);
|
| 239 |
+
assert.equal(requestJson.model, 'gpt-5.4');
|
| 240 |
+
assert.equal(requestJson.stream, false);
|
| 241 |
+
assert.equal(requestJson.tool_choice.type, 'image_generation');
|
| 242 |
+
sendJson(response, 200, {
|
| 243 |
+
output: [{ type: 'image_generation_call', status: 'completed', result: PNG_BASE64 }],
|
| 244 |
+
usage: { input_tokens: 4, output_tokens: 5, total_tokens: 9 }
|
| 245 |
+
});
|
| 246 |
+
},
|
| 247 |
+
verify: async (response) => {
|
| 248 |
+
await readJsonImage(response);
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
name: 'GPT2Image Responses image_generation SSE',
|
| 253 |
+
form: {
|
| 254 |
+
stream: true,
|
| 255 |
+
imageBackend: 'responses',
|
| 256 |
+
imageStreamingStrategy: 'responses-sse',
|
| 257 |
+
responsesModel: 'gpt-5.4',
|
| 258 |
+
clientRequestId: 'smoke-gpt2image-responses-sse'
|
| 259 |
+
},
|
| 260 |
+
handle: ({ body, request, response }) => {
|
| 261 |
+
assertPath(request, '/v1/responses');
|
| 262 |
+
const requestJson = JSON.parse(body);
|
| 263 |
+
assert.equal(requestJson.stream, true);
|
| 264 |
+
assert.equal(requestJson.tools[0].partial_images, 2);
|
| 265 |
+
sendSse(response, [
|
| 266 |
+
completedImageEvent('response.image_generation_call.partial_image', {
|
| 267 |
+
partial_image_b64: 'gpt2image-partial-base64',
|
| 268 |
+
partial_image_index: 0
|
| 269 |
+
}),
|
| 270 |
+
completedImageEvent('response.image_generation_call.completed', { item_id: 'ig_smoke' }),
|
| 271 |
+
responsesOutputItemDone(`data:image/png;base64,${PNG_BASE64}`),
|
| 272 |
+
completedImageEvent('response.completed', {
|
| 273 |
+
response: { output: [{ type: 'image_generation_call', status: 'completed', result: PNG_BASE64 }] }
|
| 274 |
+
})
|
| 275 |
+
]);
|
| 276 |
+
},
|
| 277 |
+
verify: async (response) => {
|
| 278 |
+
const events = await readSseImages(response, ['partial_image', 'completed', 'done']);
|
| 279 |
+
assert.equal(events[0].b64_json, 'gpt2image-partial-base64');
|
| 280 |
+
}
|
| 281 |
+
}
|
| 282 |
+
];
|
| 283 |
+
|
| 284 |
+
async function runCase(POST, testCase) {
|
| 285 |
+
const upstream = await startMockUpstream(testCase.handle);
|
| 286 |
+
try {
|
| 287 |
+
const response = await POST(imageRequest({ ...testCase.form, apiBaseUrl: upstream.baseUrl }));
|
| 288 |
+
await testCase.verify(response, upstream.calls);
|
| 289 |
+
console.log(`[pass] ${testCase.name}`);
|
| 290 |
+
} finally {
|
| 291 |
+
await upstream.close();
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
async function main() {
|
| 296 |
+
configureRouteEnv();
|
| 297 |
+
const { POST } = await import('../src/app/api/images/route.ts');
|
| 298 |
+
try {
|
| 299 |
+
for (const testCase of cases) await runCase(POST, testCase);
|
| 300 |
+
console.log('image upstream compatibility mock smoke passed');
|
| 301 |
+
} finally {
|
| 302 |
+
restoreProcessEnv();
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
main().catch((error) => {
|
| 307 |
+
restoreProcessEnv();
|
| 308 |
+
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
| 309 |
+
process.exit(1);
|
| 310 |
+
});
|
scripts/smoke-image-upstream-local-final-gate.mjs
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { spawn } from 'node:child_process';
|
| 4 |
+
import { fileURLToPath } from 'node:url';
|
| 5 |
+
|
| 6 |
+
import { commandFailureMessage, parseJsonPayload, printJson } from './command-center-utils.mjs';
|
| 7 |
+
import { createFixtureServer } from './local-image-upstream-fixture.mjs';
|
| 8 |
+
|
| 9 |
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
| 10 |
+
const REAL_SMOKE_SCRIPT = fileURLToPath(new URL('./smoke-image-upstream-real.mjs', import.meta.url));
|
| 11 |
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
| 12 |
+
const LOCAL_FINAL_GATE_CASE_COUNT = 5;
|
| 13 |
+
const LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS = 15_000;
|
| 14 |
+
|
| 15 |
+
function parseArgs(argv) {
|
| 16 |
+
const parsed = { help: false, timeoutMs: DEFAULT_TIMEOUT_MS };
|
| 17 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 18 |
+
const arg = argv[index];
|
| 19 |
+
if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 20 |
+
else if (arg === '--timeout-ms') parsed.timeoutMs = readTimeoutMs(readArgValue(argv, (index += 1), arg), arg);
|
| 21 |
+
else throw new Error(`未知参数:${arg}`);
|
| 22 |
+
}
|
| 23 |
+
return parsed;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
function printHelp() {
|
| 27 |
+
console.log(`Usage:
|
| 28 |
+
npm run smoke:image-upstream-local
|
| 29 |
+
npm run smoke:image-upstream-local -- --timeout-ms 30000
|
| 30 |
+
|
| 31 |
+
Starts the local image upstream fixture and runs all five independent image upstream smoke cases through the real-smoke final gate. This is a local fixture gate, not proof that third-party upstream deployments are reachable.`);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
async function main() {
|
| 35 |
+
const options = parseArgs(process.argv.slice(2));
|
| 36 |
+
if (options.help) {
|
| 37 |
+
printHelp();
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
const fixture = await startFixture();
|
| 41 |
+
try {
|
| 42 |
+
const result = await runLocalFinalGate(fixture.baseUrl, options.timeoutMs);
|
| 43 |
+
const report = parseJsonPayload(result.stdout, 'local image upstream final gate');
|
| 44 |
+
if (!result.ok) {
|
| 45 |
+
printJson({
|
| 46 |
+
ok: false,
|
| 47 |
+
local_fixture: true,
|
| 48 |
+
upstream_host: readHost(fixture.baseUrl),
|
| 49 |
+
report,
|
| 50 |
+
output: commandFailureMessage(result)
|
| 51 |
+
});
|
| 52 |
+
process.exit(1);
|
| 53 |
+
}
|
| 54 |
+
assertFinalGateReport(report);
|
| 55 |
+
printJson({
|
| 56 |
+
ok: true,
|
| 57 |
+
local_fixture: true,
|
| 58 |
+
upstream_host: readHost(fixture.baseUrl),
|
| 59 |
+
final_gate_satisfied: report.final_gate_satisfied,
|
| 60 |
+
independent_targets: report.independent_targets,
|
| 61 |
+
results: report.results.map(summarizeResult)
|
| 62 |
+
});
|
| 63 |
+
} finally {
|
| 64 |
+
await fixture.close();
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
function runLocalFinalGate(baseUrl, timeoutMs) {
|
| 69 |
+
return runCommandAsync(
|
| 70 |
+
process.execPath,
|
| 71 |
+
['--import', 'tsx', REAL_SMOKE_SCRIPT, '--allow-billable', '--require-independent-targets', '--timeout-ms', String(timeoutMs)],
|
| 72 |
+
{
|
| 73 |
+
cwd: REPO_ROOT,
|
| 74 |
+
env: buildLocalFinalGateEnv(baseUrl, timeoutMs),
|
| 75 |
+
timeoutMs: timeoutMs * LOCAL_FINAL_GATE_CASE_COUNT + LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS
|
| 76 |
+
}
|
| 77 |
+
);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function runCommandAsync(command, args, options = {}) {
|
| 81 |
+
const startedAt = Date.now();
|
| 82 |
+
return new Promise((resolve) => {
|
| 83 |
+
const child = spawn(command, args, {
|
| 84 |
+
cwd: options.cwd,
|
| 85 |
+
env: options.env,
|
| 86 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 87 |
+
});
|
| 88 |
+
let stdout = '';
|
| 89 |
+
let stderr = '';
|
| 90 |
+
let timedOut = false;
|
| 91 |
+
const timeout = setTimeout(() => {
|
| 92 |
+
timedOut = true;
|
| 93 |
+
child.kill('SIGTERM');
|
| 94 |
+
}, options.timeoutMs);
|
| 95 |
+
child.stdout.setEncoding('utf8');
|
| 96 |
+
child.stderr.setEncoding('utf8');
|
| 97 |
+
child.stdout.on('data', (chunk) => {
|
| 98 |
+
stdout += chunk;
|
| 99 |
+
});
|
| 100 |
+
child.stderr.on('data', (chunk) => {
|
| 101 |
+
stderr += chunk;
|
| 102 |
+
});
|
| 103 |
+
child.on('error', (error) => {
|
| 104 |
+
clearTimeout(timeout);
|
| 105 |
+
resolve({
|
| 106 |
+
ok: false,
|
| 107 |
+
command,
|
| 108 |
+
args,
|
| 109 |
+
stdout,
|
| 110 |
+
stderr,
|
| 111 |
+
elapsed_ms: Date.now() - startedAt,
|
| 112 |
+
error: error.message
|
| 113 |
+
});
|
| 114 |
+
});
|
| 115 |
+
child.on('close', (status, signal) => {
|
| 116 |
+
clearTimeout(timeout);
|
| 117 |
+
resolve({
|
| 118 |
+
ok: status === 0,
|
| 119 |
+
command,
|
| 120 |
+
args,
|
| 121 |
+
status,
|
| 122 |
+
stdout,
|
| 123 |
+
stderr,
|
| 124 |
+
elapsed_ms: Date.now() - startedAt,
|
| 125 |
+
...(signal ? { signal } : {}),
|
| 126 |
+
...(timedOut ? { error: `timed out after ${options.timeoutMs}ms` } : {})
|
| 127 |
+
});
|
| 128 |
+
});
|
| 129 |
+
});
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function buildLocalFinalGateEnv(baseUrl, timeoutMs) {
|
| 133 |
+
const env = stripSmokeEnv(process.env);
|
| 134 |
+
return {
|
| 135 |
+
...env,
|
| 136 |
+
IMAGE_REAL_SMOKE_SKIP_DOTENV: '1',
|
| 137 |
+
IMAGE_REAL_SMOKE_TIMEOUT_MS: String(timeoutMs),
|
| 138 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: baseUrl,
|
| 139 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'local-fixture-key-original',
|
| 140 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL: baseUrl,
|
| 141 |
+
IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'local-fixture-key-gaoren',
|
| 142 |
+
IMAGE_REAL_SMOKE_SUB2API_BASE_URL: baseUrl,
|
| 143 |
+
IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'local-fixture-key-sub2api',
|
| 144 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: baseUrl,
|
| 145 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'local-fixture-key-sub2api-responses',
|
| 146 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL: 'gpt-5.4',
|
| 147 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: baseUrl,
|
| 148 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'local-fixture-key-gpt2image',
|
| 149 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL: 'gpt-5.4'
|
| 150 |
+
};
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
function stripSmokeEnv(source) {
|
| 154 |
+
const env = { ...source };
|
| 155 |
+
for (const key of Object.keys(env)) {
|
| 156 |
+
if (isSmokeEnvKey(key)) delete env[key];
|
| 157 |
+
}
|
| 158 |
+
return env;
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
function isSmokeEnvKey(key) {
|
| 162 |
+
return (
|
| 163 |
+
key.startsWith('IMAGE_REAL_SMOKE_') ||
|
| 164 |
+
key.startsWith('OPENAI_CHANNEL_') ||
|
| 165 |
+
key === 'OPENAI_API_BASE_URL' ||
|
| 166 |
+
key === 'OPENAI_API_KEY' ||
|
| 167 |
+
key === 'OPENAI_RESPONSES_API_MODEL' ||
|
| 168 |
+
key === 'OPENAI_ROUTING_STRATEGY' ||
|
| 169 |
+
key === 'OPENAI_CHANNELS_JSON' ||
|
| 170 |
+
key === 'IMAGE_OUTPUT_DIR' ||
|
| 171 |
+
key === 'APP_PASSWORD' ||
|
| 172 |
+
key === 'AGENT_API_TOKEN'
|
| 173 |
+
);
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
async function startFixture() {
|
| 177 |
+
const server = createFixtureServer();
|
| 178 |
+
await new Promise((resolve, reject) => {
|
| 179 |
+
server.once('error', reject);
|
| 180 |
+
server.listen(0, '127.0.0.1', resolve);
|
| 181 |
+
});
|
| 182 |
+
const address = server.address();
|
| 183 |
+
if (!address || typeof address !== 'object') throw new Error('local fixture did not expose a TCP address');
|
| 184 |
+
return {
|
| 185 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 186 |
+
close: () => closeServer(server)
|
| 187 |
+
};
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
function closeServer(server) {
|
| 191 |
+
return new Promise((resolve, reject) => {
|
| 192 |
+
server.close((error) => {
|
| 193 |
+
if (error) reject(error);
|
| 194 |
+
else resolve();
|
| 195 |
+
});
|
| 196 |
+
});
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
function assertFinalGateReport(report) {
|
| 200 |
+
if (report?.final_gate_satisfied !== true) throw new Error('local final gate did not satisfy final_gate_satisfied=true');
|
| 201 |
+
if (!Array.isArray(report.results) || report.results.length !== 5) {
|
| 202 |
+
throw new Error('local final gate did not run all five independent upstream cases');
|
| 203 |
+
}
|
| 204 |
+
const failed = report.results.find((item) => item.ok !== true || item.skipped === true);
|
| 205 |
+
if (failed) throw new Error(`local final gate case failed: ${failed.id || 'unknown'}`);
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
function summarizeResult(result) {
|
| 209 |
+
return {
|
| 210 |
+
id: result.id,
|
| 211 |
+
status: result.status,
|
| 212 |
+
content_type: result.content_type,
|
| 213 |
+
event_types: result.event_types,
|
| 214 |
+
image_count: result.image_count,
|
| 215 |
+
done_image_count: result.done_image_count,
|
| 216 |
+
first_b64_length: result.first_b64_length,
|
| 217 |
+
elapsed_ms: result.elapsed_ms
|
| 218 |
+
};
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function readArgValue(argv, index, flag) {
|
| 222 |
+
const value = argv[index];
|
| 223 |
+
if (!value || value.startsWith('--')) throw new Error(`${flag} 缺少参数值。`);
|
| 224 |
+
return value;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function readTimeoutMs(value, source) {
|
| 228 |
+
const parsed = Number(value);
|
| 229 |
+
if (!Number.isInteger(parsed) || parsed < 1000) throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`);
|
| 230 |
+
return parsed;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function readHost(value) {
|
| 234 |
+
try {
|
| 235 |
+
return new URL(value).host;
|
| 236 |
+
} catch {
|
| 237 |
+
return 'invalid-url';
|
| 238 |
+
}
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
main().catch((error) => {
|
| 242 |
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
| 243 |
+
process.exit(1);
|
| 244 |
+
});
|
scripts/smoke-image-upstream-local-final-gate.test.mjs
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawnSync } from 'node:child_process';
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
import { describe, it } from 'node:test';
|
| 5 |
+
|
| 6 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 7 |
+
const scriptPath = fileURLToPath(new URL('./smoke-image-upstream-local-final-gate.mjs', import.meta.url));
|
| 8 |
+
|
| 9 |
+
describe('local image upstream final gate smoke launcher', () => {
|
| 10 |
+
it('runs all independent upstream cases against the local fixture', () => {
|
| 11 |
+
const result = spawnSync(process.execPath, [scriptPath, '--timeout-ms', '30000'], {
|
| 12 |
+
cwd: repoRoot,
|
| 13 |
+
encoding: 'utf8'
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 17 |
+
assert.equal(result.stderr.trim(), '');
|
| 18 |
+
assert.doesNotMatch(result.stdout, /local-fixture-key/);
|
| 19 |
+
const report = JSON.parse(result.stdout);
|
| 20 |
+
assert.equal(report.ok, true);
|
| 21 |
+
assert.equal(report.local_fixture, true);
|
| 22 |
+
assert.equal(report.final_gate_satisfied, true);
|
| 23 |
+
assert.equal(report.independent_targets.configuration_complete, true);
|
| 24 |
+
assert.equal(report.results.length, 5);
|
| 25 |
+
assert.deepEqual(
|
| 26 |
+
report.results.map((item) => item.id),
|
| 27 |
+
[
|
| 28 |
+
'original-images-json',
|
| 29 |
+
'gaoren-images-sse',
|
| 30 |
+
'sub2api-images-sse',
|
| 31 |
+
'sub2api-responses-json',
|
| 32 |
+
'gpt2image-responses-sse'
|
| 33 |
+
]
|
| 34 |
+
);
|
| 35 |
+
assert.equal(report.results.every((item) => item.status === 200), true);
|
| 36 |
+
assert.equal(report.results.every((item) => item.first_b64_length === 92), true);
|
| 37 |
+
});
|
| 38 |
+
|
| 39 |
+
it('prints help without starting the fixture', () => {
|
| 40 |
+
const result = spawnSync(process.execPath, [scriptPath, '--help'], {
|
| 41 |
+
cwd: repoRoot,
|
| 42 |
+
encoding: 'utf8'
|
| 43 |
+
});
|
| 44 |
+
|
| 45 |
+
assert.equal(result.status, 0);
|
| 46 |
+
assert.match(result.stdout, /smoke:image-upstream-local/);
|
| 47 |
+
assert.match(result.stdout, /local fixture gate/);
|
| 48 |
+
});
|
| 49 |
+
});
|
scripts/smoke-image-upstream-real.mjs
ADDED
|
@@ -0,0 +1,791 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import crypto from 'node:crypto';
|
| 3 |
+
import fs from 'node:fs';
|
| 4 |
+
|
| 5 |
+
const originalEnv = { ...process.env };
|
| 6 |
+
const CASES = [
|
| 7 |
+
{ id: 'original-images-json', prefix: 'IMAGE_REAL_SMOKE_ORIGINAL', stream: false },
|
| 8 |
+
{ id: 'gaoren-images-sse', prefix: 'IMAGE_REAL_SMOKE_GAOREN', stream: true, strategy: 'newapi-keepalive-sse' },
|
| 9 |
+
{ id: 'sub2api-images-sse', prefix: 'IMAGE_REAL_SMOKE_SUB2API', stream: true, strategy: 'newapi-keepalive-sse' },
|
| 10 |
+
{
|
| 11 |
+
id: 'sub2api-responses-json',
|
| 12 |
+
prefix: 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES',
|
| 13 |
+
fallbackPrefix: 'IMAGE_REAL_SMOKE_SUB2API',
|
| 14 |
+
stream: false,
|
| 15 |
+
backend: 'responses-image-generation'
|
| 16 |
+
},
|
| 17 |
+
{
|
| 18 |
+
id: 'gpt2image-responses-sse',
|
| 19 |
+
prefix: 'IMAGE_REAL_SMOKE_GPT2IMAGE',
|
| 20 |
+
stream: true,
|
| 21 |
+
strategy: 'responses-sse',
|
| 22 |
+
backend: 'responses-image-generation'
|
| 23 |
+
}
|
| 24 |
+
];
|
| 25 |
+
const SERVER_CHANNEL_CASES = [
|
| 26 |
+
{ id: 'server-channel-images-json', prefix: 'IMAGE_REAL_SMOKE_SERVER', stream: false, serverChannel: true },
|
| 27 |
+
{
|
| 28 |
+
id: 'server-channel-images-sse',
|
| 29 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 30 |
+
stream: true,
|
| 31 |
+
strategy: 'newapi-keepalive-sse',
|
| 32 |
+
serverChannel: true
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
id: 'server-channel-responses-sse',
|
| 36 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 37 |
+
stream: true,
|
| 38 |
+
strategy: 'responses-sse',
|
| 39 |
+
backend: 'responses-image-generation',
|
| 40 |
+
serverChannel: true
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
id: 'server-channel-responses-json',
|
| 44 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 45 |
+
stream: false,
|
| 46 |
+
backend: 'responses-image-generation',
|
| 47 |
+
serverChannel: true
|
| 48 |
+
},
|
| 49 |
+
{
|
| 50 |
+
id: 'server-channel-agent-images-sse',
|
| 51 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 52 |
+
stream: true,
|
| 53 |
+
strategy: 'newapi-keepalive-sse',
|
| 54 |
+
serverChannel: true,
|
| 55 |
+
endpoint: 'agent-generate'
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
id: 'server-channel-agent-responses-sse',
|
| 59 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 60 |
+
stream: true,
|
| 61 |
+
strategy: 'responses-sse',
|
| 62 |
+
backend: 'responses-image-generation',
|
| 63 |
+
serverChannel: true,
|
| 64 |
+
endpoint: 'agent-generate'
|
| 65 |
+
}
|
| 66 |
+
];
|
| 67 |
+
|
| 68 |
+
const argv = process.argv.slice(2);
|
| 69 |
+
let options;
|
| 70 |
+
let exitCode = 0;
|
| 71 |
+
let forceExitAfterReport = false;
|
| 72 |
+
try {
|
| 73 |
+
if (!isHelpRequested(argv)) loadDotEnvFiles(argv);
|
| 74 |
+
options = parseArgs(argv);
|
| 75 |
+
if (options.help) {
|
| 76 |
+
printUsage();
|
| 77 |
+
} else {
|
| 78 |
+
configureRouteEnv();
|
| 79 |
+
const availableCases = options.includeServerChannel ? [...CASES, ...SERVER_CHANNEL_CASES] : CASES;
|
| 80 |
+
const selectedCases = availableCases.filter(
|
| 81 |
+
(testCase) => options.caseId === 'all' || testCase.id === options.caseId
|
| 82 |
+
);
|
| 83 |
+
if (selectedCases.length === 0) throw new Error(`未知真实 smoke 场景:${options.caseId}`);
|
| 84 |
+
const billablePreflight = buildBillablePreflight(selectedCases);
|
| 85 |
+
const results = [];
|
| 86 |
+
let routeHandlers;
|
| 87 |
+
for (const testCase of selectedCases) {
|
| 88 |
+
const loadHandlers = async () => {
|
| 89 |
+
routeHandlers ||= await loadRouteHandlers();
|
| 90 |
+
return routeHandlers;
|
| 91 |
+
};
|
| 92 |
+
const result = await runCase(loadHandlers, testCase, billablePreflight);
|
| 93 |
+
results.push(result);
|
| 94 |
+
if (result.timed_out) break;
|
| 95 |
+
}
|
| 96 |
+
const independentTargetSummary = buildIndependentTargetSummary(results, options.requireIndependentTargets);
|
| 97 |
+
const unselectedRequiredCases = options.requireIndependentTargets
|
| 98 |
+
? independentTargetSummary?.unselected_required_cases || []
|
| 99 |
+
: [];
|
| 100 |
+
const invalidRequiredCases = options.requireIndependentTargets
|
| 101 |
+
? independentTargetSummary?.invalid_cases || []
|
| 102 |
+
: [];
|
| 103 |
+
const blockedCases = results.filter((item) => item.blocked).map((item) => item.id);
|
| 104 |
+
const blockedRequiredCases = options.requireIndependentTargets
|
| 105 |
+
? blockedCases.filter((id) => CASES.some((testCase) => testCase.id === id))
|
| 106 |
+
: [];
|
| 107 |
+
const skippedRequiredCases = options.requireIndependentTargets
|
| 108 |
+
? results.filter((item) => item.skipped && !item.blocked && !item.server_channel).map((item) => item.id)
|
| 109 |
+
: [];
|
| 110 |
+
const missingRequiredCaseSet = new Set([...unselectedRequiredCases, ...skippedRequiredCases]);
|
| 111 |
+
const missingRequiredCases = CASES.map((testCase) => testCase.id).filter((id) =>
|
| 112 |
+
missingRequiredCaseSet.has(id)
|
| 113 |
+
);
|
| 114 |
+
const finalGateSatisfied = isFinalGateSatisfied(results, missingRequiredCases);
|
| 115 |
+
const report = {
|
| 116 |
+
ok:
|
| 117 |
+
results.every((item) => item.ok || item.skipped) &&
|
| 118 |
+
missingRequiredCases.length === 0 &&
|
| 119 |
+
invalidRequiredCases.length === 0,
|
| 120 |
+
billable: options.allowBillable,
|
| 121 |
+
final_gate_satisfied: finalGateSatisfied,
|
| 122 |
+
...(independentTargetSummary ? { independent_targets: independentTargetSummary } : {}),
|
| 123 |
+
...(unselectedRequiredCases.length > 0 ? { unselected_required_cases: unselectedRequiredCases } : {}),
|
| 124 |
+
...(invalidRequiredCases.length > 0 ? { invalid_required_count: invalidRequiredCases.length } : {}),
|
| 125 |
+
...(invalidRequiredCases.length > 0 ? { invalid_required_cases: invalidRequiredCases } : {}),
|
| 126 |
+
...(blockedCases.length > 0 ? { blocked_count: blockedCases.length } : {}),
|
| 127 |
+
...(blockedCases.length > 0 ? { blocked_cases: blockedCases } : {}),
|
| 128 |
+
...(blockedRequiredCases.length > 0 ? { blocked_required_count: blockedRequiredCases.length } : {}),
|
| 129 |
+
...(blockedRequiredCases.length > 0 ? { blocked_required_cases: blockedRequiredCases } : {}),
|
| 130 |
+
...(skippedRequiredCases.length > 0 ? { skipped_required_cases: skippedRequiredCases } : {}),
|
| 131 |
+
...(missingRequiredCases.length > 0 ? { missing_required_count: missingRequiredCases.length } : {}),
|
| 132 |
+
...(missingRequiredCases.length > 0 ? { missing_required_cases: missingRequiredCases } : {}),
|
| 133 |
+
results
|
| 134 |
+
};
|
| 135 |
+
forceExitAfterReport = results.some((item) => item.timed_out);
|
| 136 |
+
await writeStdout(`${JSON.stringify(report, null, 2)}\n`);
|
| 137 |
+
exitCode = report.ok ? 0 : 1;
|
| 138 |
+
}
|
| 139 |
+
} catch (error) {
|
| 140 |
+
console.error(error instanceof Error ? error.message : String(error));
|
| 141 |
+
exitCode = 1;
|
| 142 |
+
} finally {
|
| 143 |
+
restoreProcessEnv();
|
| 144 |
+
}
|
| 145 |
+
if (forceExitAfterReport) {
|
| 146 |
+
process.exit(exitCode);
|
| 147 |
+
}
|
| 148 |
+
process.exitCode = exitCode;
|
| 149 |
+
|
| 150 |
+
function writeStdout(text) {
|
| 151 |
+
return new Promise((resolve, reject) => {
|
| 152 |
+
process.stdout.write(text, (error) => {
|
| 153 |
+
if (error) reject(error);
|
| 154 |
+
else resolve();
|
| 155 |
+
});
|
| 156 |
+
});
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
function isFinalGateSatisfied(results, missingRequiredCases) {
|
| 160 |
+
if (!options.requireIndependentTargets || !options.allowBillable || missingRequiredCases.length > 0) return false;
|
| 161 |
+
const passedIndependentCases = new Set(
|
| 162 |
+
results.filter((item) => !item.server_channel && !item.skipped && item.ok).map((item) => item.id)
|
| 163 |
+
);
|
| 164 |
+
return CASES.every((testCase) => passedIndependentCases.has(testCase.id));
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function buildBillablePreflight(selectedCases) {
|
| 168 |
+
if (!options.allowBillable) return {};
|
| 169 |
+
if (selectedCases.some((testCase) => readInvalidEnv(readTarget(testCase)).length > 0)) {
|
| 170 |
+
return { blockBillable: true, blockReason: 'blocked by invalid base url env' };
|
| 171 |
+
}
|
| 172 |
+
if (options.requireIndependentTargets && !isRequiredIndependentGateConfigured(selectedCases)) {
|
| 173 |
+
return { blockBillable: true, blockReason: 'blocked by incomplete independent target configuration' };
|
| 174 |
+
}
|
| 175 |
+
return {};
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function isRequiredIndependentGateConfigured(selectedCases) {
|
| 179 |
+
const selectedIndependentCaseIds = new Set(
|
| 180 |
+
selectedCases.filter((testCase) => !testCase.serverChannel).map((testCase) => testCase.id)
|
| 181 |
+
);
|
| 182 |
+
return CASES.every((testCase) => {
|
| 183 |
+
if (!selectedIndependentCaseIds.has(testCase.id)) return false;
|
| 184 |
+
const target = readTarget(testCase);
|
| 185 |
+
return readInvalidEnv(target).length === 0 && isRunnableTarget(target);
|
| 186 |
+
});
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function parseArgs(argv) {
|
| 190 |
+
const parsed = {
|
| 191 |
+
allowBillable: false,
|
| 192 |
+
caseId: 'all',
|
| 193 |
+
envFilePath: undefined,
|
| 194 |
+
help: false,
|
| 195 |
+
includeServerChannel: false,
|
| 196 |
+
requireIndependentTargets: false,
|
| 197 |
+
timeoutMs: readTimeoutMs(env('IMAGE_REAL_SMOKE_TIMEOUT_MS') || '240000', 'IMAGE_REAL_SMOKE_TIMEOUT_MS')
|
| 198 |
+
};
|
| 199 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 200 |
+
const arg = argv[index];
|
| 201 |
+
if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 202 |
+
else if (arg === '--env-file') parsed.envFilePath = readArgValue(argv, (index += 1), arg);
|
| 203 |
+
else if (arg === '--env-file-if-exists') parsed.envFileIfExistsPath = readArgValue(argv, (index += 1), arg);
|
| 204 |
+
else if (arg === '--include-server-channel') parsed.includeServerChannel = true;
|
| 205 |
+
else if (arg === '--require-independent-targets') parsed.requireIndependentTargets = true;
|
| 206 |
+
else if (arg === '--timeout-ms') parsed.timeoutMs = readTimeoutMs(readArgValue(argv, (index += 1), arg), arg);
|
| 207 |
+
else if (arg === '--case') parsed.caseId = readArgValue(argv, (index += 1), arg);
|
| 208 |
+
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 209 |
+
else throw new Error(`未知参数:${arg}`);
|
| 210 |
+
}
|
| 211 |
+
return parsed;
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
function isHelpRequested(argv) {
|
| 215 |
+
return argv.includes('--help') || argv.includes('-h');
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function readTimeoutMs(value, source) {
|
| 219 |
+
const parsed = Number(value);
|
| 220 |
+
if (!Number.isInteger(parsed) || parsed < 1000) {
|
| 221 |
+
throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`);
|
| 222 |
+
}
|
| 223 |
+
return parsed;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
function readArgValue(argv, index, flag) {
|
| 227 |
+
const value = argv[index];
|
| 228 |
+
if (!value || value.startsWith('--')) throw new Error(`${flag} 缺少参数值。`);
|
| 229 |
+
return value;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function loadDotEnvFiles(argv) {
|
| 233 |
+
if (env('IMAGE_REAL_SMOKE_SKIP_DOTENV') !== '1') {
|
| 234 |
+
loadEnvFileIfPresent('.env.local', { overrideLoadedValues: false });
|
| 235 |
+
}
|
| 236 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 237 |
+
if (argv[index] !== '--env-file') continue;
|
| 238 |
+
loadEnvFile(readArgValue(argv, (index += 1), '--env-file'), { overrideLoadedValues: true });
|
| 239 |
+
}
|
| 240 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 241 |
+
if (argv[index] !== '--env-file-if-exists') continue;
|
| 242 |
+
loadEnvFileIfPresent(readArgValue(argv, (index += 1), '--env-file-if-exists'), { overrideLoadedValues: true });
|
| 243 |
+
}
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
function loadEnvFileIfPresent(filepath, options) {
|
| 247 |
+
if (!fs.existsSync(filepath)) return;
|
| 248 |
+
loadEnvFile(filepath, options);
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function loadEnvFile(filepath, options) {
|
| 252 |
+
if (!fs.existsSync(filepath)) throw new Error(`--env-file 指定的文件不存在:${filepath}`);
|
| 253 |
+
for (const line of fs.readFileSync(filepath, 'utf8').split(/\r?\n/)) {
|
| 254 |
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
| 255 |
+
if (!match || !shouldSetLoadedEnv(match[1], options)) continue;
|
| 256 |
+
process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, '').trim();
|
| 257 |
+
}
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
function shouldSetLoadedEnv(key, options) {
|
| 261 |
+
if (originalEnv[key] !== undefined) return false;
|
| 262 |
+
return options.overrideLoadedValues || process.env[key] === undefined;
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function restoreProcessEnv() {
|
| 266 |
+
for (const key of Object.keys(process.env)) {
|
| 267 |
+
if (!(key in originalEnv)) delete process.env[key];
|
| 268 |
+
}
|
| 269 |
+
for (const [key, value] of Object.entries(originalEnv)) process.env[key] = value;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
function configureRouteEnv() {
|
| 273 |
+
process.env.APP_LOG_LEVEL = 'warn';
|
| 274 |
+
process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'indexeddb';
|
| 275 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 276 |
+
process.env.AGENT_STATE_BACKEND = 'memory';
|
| 277 |
+
process.env.IMAGE_OUTPUT_DIR = process.env.IMAGE_OUTPUT_DIR || 'generated-images/.real-smoke';
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
async function loadRouteHandlers() {
|
| 281 |
+
const imageRoute = await import('../src/app/api/images/route.ts');
|
| 282 |
+
const agentGenerateRoute = await import('../src/app/api/agent/images/generate/route.ts');
|
| 283 |
+
return {
|
| 284 |
+
images: readPostHandler(imageRoute, '/api/images'),
|
| 285 |
+
agentGenerate: readPostHandler(agentGenerateRoute, '/api/agent/images/generate')
|
| 286 |
+
};
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
function readPostHandler(routeModule, label) {
|
| 290 |
+
const handler = routeModule.POST || routeModule.default?.POST || routeModule['module.exports']?.POST;
|
| 291 |
+
if (typeof handler !== 'function') {
|
| 292 |
+
throw new Error(`${label} route 缺少 POST handler。`);
|
| 293 |
+
}
|
| 294 |
+
return handler;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
async function runCase(loadRouteHandlersForBillable, testCase, preflight = {}) {
|
| 298 |
+
const target = readTarget(testCase);
|
| 299 |
+
const invalidEnv = readInvalidEnv(target);
|
| 300 |
+
if (invalidEnv.length > 0) return invalid(testCase, target, invalidEnv);
|
| 301 |
+
if (!isRunnableTarget(target)) return skipped(testCase, target);
|
| 302 |
+
if (preflight.blockBillable && options.allowBillable) return blocked(testCase, target, preflight.blockReason);
|
| 303 |
+
if (!options.allowBillable) {
|
| 304 |
+
return {
|
| 305 |
+
id: testCase.id,
|
| 306 |
+
skipped: true,
|
| 307 |
+
ok: true,
|
| 308 |
+
reason: 'requires --allow-billable',
|
| 309 |
+
upstream_host: readHost(target.baseUrl),
|
| 310 |
+
...(target.serverChannel ? { server_channel: true } : {})
|
| 311 |
+
};
|
| 312 |
+
}
|
| 313 |
+
const startedAt = Date.now();
|
| 314 |
+
const routeHandlers = await loadRouteHandlersForBillable();
|
| 315 |
+
const abortController = new AbortController();
|
| 316 |
+
return withCaseTimeout(
|
| 317 |
+
runBillableCase(routeHandlers, testCase, target, startedAt, abortController.signal),
|
| 318 |
+
testCase,
|
| 319 |
+
target,
|
| 320 |
+
startedAt,
|
| 321 |
+
abortController
|
| 322 |
+
);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
async function runBillableCase(routeHandlers, testCase, target, startedAt, signal) {
|
| 326 |
+
const outputFilesBefore = snapshotRealSmokeOutputFiles();
|
| 327 |
+
if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') {
|
| 328 |
+
process.env.OPENAI_RESPONSES_API_MODEL = target.responsesModel;
|
| 329 |
+
}
|
| 330 |
+
try {
|
| 331 |
+
const response =
|
| 332 |
+
testCase.endpoint === 'agent-generate'
|
| 333 |
+
? await routeHandlers.agentGenerate(agentGenerateRequest(testCase, target, signal))
|
| 334 |
+
: await routeHandlers.images(imageRequest(testCase, target, signal));
|
| 335 |
+
const summary =
|
| 336 |
+
testCase.endpoint === 'agent-generate'
|
| 337 |
+
? await summarizeAgentResponse(response)
|
| 338 |
+
: await summarizeResponse(response);
|
| 339 |
+
return {
|
| 340 |
+
id: testCase.id,
|
| 341 |
+
ok: isSuccessfulBillableSmokeResponse(response, summary),
|
| 342 |
+
status: response.status,
|
| 343 |
+
elapsed_ms: Date.now() - startedAt,
|
| 344 |
+
upstream_host: readHost(target.baseUrl),
|
| 345 |
+
...(target.serverChannel ? { server_channel: true } : {}),
|
| 346 |
+
...summary
|
| 347 |
+
};
|
| 348 |
+
} finally {
|
| 349 |
+
removeNewRealSmokeOutputFiles(outputFilesBefore);
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function withCaseTimeout(promise, testCase, target, startedAt, abortController) {
|
| 354 |
+
return new Promise((resolve, reject) => {
|
| 355 |
+
let settled = false;
|
| 356 |
+
const timeout = setTimeout(() => {
|
| 357 |
+
settled = true;
|
| 358 |
+
abortController.abort();
|
| 359 |
+
resolve({
|
| 360 |
+
id: testCase.id,
|
| 361 |
+
ok: false,
|
| 362 |
+
timed_out: true,
|
| 363 |
+
elapsed_ms: Date.now() - startedAt,
|
| 364 |
+
upstream_host: readHost(target.baseUrl),
|
| 365 |
+
...(target.serverChannel ? { server_channel: true } : {}),
|
| 366 |
+
error: `real upstream smoke timed out after ${options.timeoutMs}ms`
|
| 367 |
+
});
|
| 368 |
+
}, options.timeoutMs);
|
| 369 |
+
promise.then(
|
| 370 |
+
(value) => {
|
| 371 |
+
if (settled) return;
|
| 372 |
+
settled = true;
|
| 373 |
+
clearTimeout(timeout);
|
| 374 |
+
resolve(value);
|
| 375 |
+
},
|
| 376 |
+
(error) => {
|
| 377 |
+
if (settled) return;
|
| 378 |
+
settled = true;
|
| 379 |
+
clearTimeout(timeout);
|
| 380 |
+
reject(error);
|
| 381 |
+
}
|
| 382 |
+
);
|
| 383 |
+
});
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
function readTarget(testCase) {
|
| 387 |
+
const basePrefix = testCase.prefix;
|
| 388 |
+
const fallbackPrefix = testCase.fallbackPrefix || basePrefix;
|
| 389 |
+
const requiresResponsesModel = testCase.backend === 'responses-image-generation';
|
| 390 |
+
if (testCase.serverChannel) {
|
| 391 |
+
const serverBaseUrl = readEnvEntry(`${basePrefix}_BASE_URL`) || readFirstConfiguredServerBaseUrl();
|
| 392 |
+
return {
|
| 393 |
+
serverChannel: true,
|
| 394 |
+
requiresResponsesModel,
|
| 395 |
+
baseUrl: serverBaseUrl?.value,
|
| 396 |
+
baseUrlKey: serverBaseUrl?.key,
|
| 397 |
+
hasServerCredential: Boolean(env('OPENAI_API_KEY') || readFirstConfiguredServerApiKeys()),
|
| 398 |
+
model: env(`${basePrefix}_MODEL`) || 'gpt-image-2',
|
| 399 |
+
responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'),
|
| 400 |
+
size: env(`${basePrefix}_SIZE`) || '1024x1024',
|
| 401 |
+
quality: env(`${basePrefix}_QUALITY`) || 'low'
|
| 402 |
+
};
|
| 403 |
+
}
|
| 404 |
+
const baseUrl = readFirstEnvEntry(readSmokeEnvAlternatives(testCase, 'BASE_URL'));
|
| 405 |
+
const apiKey = readFirstEnvEntry(readSmokeEnvAlternatives(testCase, 'API_KEY'));
|
| 406 |
+
return {
|
| 407 |
+
requiresResponsesModel,
|
| 408 |
+
baseUrl: baseUrl?.value,
|
| 409 |
+
baseUrlKey: baseUrl?.key,
|
| 410 |
+
apiKey: apiKey?.value,
|
| 411 |
+
model: env(`${basePrefix}_MODEL`) || env(`${fallbackPrefix}_MODEL`) || 'gpt-image-2',
|
| 412 |
+
responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'),
|
| 413 |
+
size: env(`${basePrefix}_SIZE`) || env(`${fallbackPrefix}_SIZE`) || '1024x1024',
|
| 414 |
+
quality: env(`${basePrefix}_QUALITY`) || env(`${fallbackPrefix}_QUALITY`) || 'low'
|
| 415 |
+
};
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
function isRunnableTarget(target) {
|
| 419 |
+
if (!target.baseUrl) return false;
|
| 420 |
+
if (target.requiresResponsesModel && !target.responsesModel) return false;
|
| 421 |
+
if (target.serverChannel) return target.hasServerCredential;
|
| 422 |
+
if (!target.apiKey) return false;
|
| 423 |
+
return true;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
function readInvalidEnv(target) {
|
| 427 |
+
if (!target.baseUrl || !target.baseUrlKey) return [];
|
| 428 |
+
const reason = readBaseUrlValidationReason(target.baseUrl);
|
| 429 |
+
return reason ? [{ key: target.baseUrlKey, reason }] : [];
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
function readBaseUrlValidationReason(value) {
|
| 433 |
+
let url;
|
| 434 |
+
try {
|
| 435 |
+
url = new URL(value);
|
| 436 |
+
} catch {
|
| 437 |
+
return 'must_be_http_or_https_absolute_url';
|
| 438 |
+
}
|
| 439 |
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'must_use_http_or_https';
|
| 440 |
+
if (url.username || url.password) return 'must_not_include_credentials';
|
| 441 |
+
if (url.search || url.hash) return 'must_not_include_query_or_fragment';
|
| 442 |
+
return undefined;
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
function skipped(testCase, target) {
|
| 446 |
+
const missingEnvAny = readMissingEnvAny(testCase, target);
|
| 447 |
+
return {
|
| 448 |
+
id: testCase.id,
|
| 449 |
+
skipped: true,
|
| 450 |
+
ok: true,
|
| 451 |
+
reason: readSkippedReason(target),
|
| 452 |
+
...(missingEnvAny.length > 0 ? { missing_env_any: missingEnvAny } : {}),
|
| 453 |
+
...(target.serverChannel ? { server_channel: true } : {})
|
| 454 |
+
};
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
function invalid(testCase, target, invalidEnv) {
|
| 458 |
+
return {
|
| 459 |
+
id: testCase.id,
|
| 460 |
+
ok: false,
|
| 461 |
+
invalid: true,
|
| 462 |
+
reason: 'invalid base url env',
|
| 463 |
+
invalid_env: invalidEnv,
|
| 464 |
+
...(target.serverChannel ? { server_channel: true } : {})
|
| 465 |
+
};
|
| 466 |
+
}
|
| 467 |
+
|
| 468 |
+
function blocked(testCase, target, reason = 'blocked by invalid base url env') {
|
| 469 |
+
return {
|
| 470 |
+
id: testCase.id,
|
| 471 |
+
skipped: true,
|
| 472 |
+
blocked: true,
|
| 473 |
+
ok: true,
|
| 474 |
+
reason,
|
| 475 |
+
upstream_host: readHost(target.baseUrl),
|
| 476 |
+
...(target.serverChannel ? { server_channel: true } : {})
|
| 477 |
+
};
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
function buildIndependentTargetSummary(results, requireIndependentTargets = false) {
|
| 481 |
+
const independentResults = results.filter((item) => !item.server_channel);
|
| 482 |
+
const requiredCases = CASES.map((testCase) => testCase.id);
|
| 483 |
+
if (independentResults.length === 0 && !requireIndependentTargets) return undefined;
|
| 484 |
+
const selectedCases = independentResults.map((item) => item.id);
|
| 485 |
+
const unselectedRequiredCases = requiredCases.filter((id) => !selectedCases.includes(id));
|
| 486 |
+
const configuredCases = independentResults
|
| 487 |
+
.filter(
|
| 488 |
+
(item) =>
|
| 489 |
+
(!Array.isArray(item.missing_env_any) || item.missing_env_any.length === 0) &&
|
| 490 |
+
(!Array.isArray(item.invalid_env) || item.invalid_env.length === 0)
|
| 491 |
+
)
|
| 492 |
+
.map((item) => item.id);
|
| 493 |
+
const missingCases = independentResults
|
| 494 |
+
.filter((item) => Array.isArray(item.missing_env_any) && item.missing_env_any.length > 0)
|
| 495 |
+
.map((item) => item.id);
|
| 496 |
+
const invalidCases = independentResults
|
| 497 |
+
.filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0)
|
| 498 |
+
.map((item) => item.id);
|
| 499 |
+
return {
|
| 500 |
+
required_count: requiredCases.length,
|
| 501 |
+
required_cases: requiredCases,
|
| 502 |
+
selected_cases: selectedCases,
|
| 503 |
+
unselected_required_count: unselectedRequiredCases.length,
|
| 504 |
+
unselected_required_cases: unselectedRequiredCases,
|
| 505 |
+
configuration_complete:
|
| 506 |
+
unselectedRequiredCases.length === 0 && missingCases.length === 0 && invalidCases.length === 0,
|
| 507 |
+
selected_count: independentResults.length,
|
| 508 |
+
configured_count: configuredCases.length,
|
| 509 |
+
missing_count: missingCases.length,
|
| 510 |
+
configured_cases: configuredCases,
|
| 511 |
+
missing_cases: missingCases,
|
| 512 |
+
invalid_count: invalidCases.length,
|
| 513 |
+
invalid_cases: invalidCases,
|
| 514 |
+
invalid_env: Object.fromEntries(
|
| 515 |
+
independentResults
|
| 516 |
+
.filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0)
|
| 517 |
+
.map((item) => [item.id, item.invalid_env])
|
| 518 |
+
),
|
| 519 |
+
final_gate_command:
|
| 520 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable'
|
| 521 |
+
};
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
function readSkippedReason(target) {
|
| 525 |
+
if (!target.baseUrl) return target.serverChannel ? 'missing server channel base url env' : 'missing base url env';
|
| 526 |
+
if (target.serverChannel && !target.hasServerCredential) return 'missing server channel api key env';
|
| 527 |
+
if (!target.serverChannel && !target.apiKey) return 'missing api key env';
|
| 528 |
+
if (target.requiresResponsesModel && !target.responsesModel) return 'missing responses model env';
|
| 529 |
+
return target.serverChannel ? 'missing server channel api key env' : 'missing api key env';
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
function readMissingEnvAny(testCase, target) {
|
| 533 |
+
const groups = [];
|
| 534 |
+
if (!target.baseUrl) {
|
| 535 |
+
groups.push(readSmokeEnvAlternatives(testCase, 'BASE_URL'));
|
| 536 |
+
}
|
| 537 |
+
if (target.serverChannel && target.baseUrl && !target.hasServerCredential) {
|
| 538 |
+
groups.push(['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS']);
|
| 539 |
+
}
|
| 540 |
+
if (!target.serverChannel && target.baseUrl && !target.apiKey) {
|
| 541 |
+
groups.push(readSmokeEnvAlternatives(testCase, 'API_KEY'));
|
| 542 |
+
}
|
| 543 |
+
if (target.requiresResponsesModel && target.baseUrl && !target.responsesModel) {
|
| 544 |
+
groups.push(readResponsesModelEnvAlternatives(testCase));
|
| 545 |
+
}
|
| 546 |
+
return groups;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
function readSmokeEnvAlternatives(testCase, suffix) {
|
| 550 |
+
const keys = [`${testCase.prefix}_${suffix}`];
|
| 551 |
+
if (testCase.fallbackPrefix && testCase.fallbackPrefix !== testCase.prefix) {
|
| 552 |
+
keys.push(`${testCase.fallbackPrefix}_${suffix}`);
|
| 553 |
+
}
|
| 554 |
+
return keys;
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
function readResponsesModelEnvAlternatives(testCase) {
|
| 558 |
+
return [`${testCase.prefix}_RESPONSES_MODEL`, 'OPENAI_RESPONSES_API_MODEL'];
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
function env(key) {
|
| 562 |
+
const value = process.env[key];
|
| 563 |
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function readEnvEntry(key) {
|
| 567 |
+
const value = env(key);
|
| 568 |
+
return value ? { key, value } : undefined;
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
function readFirstEnvEntry(keys) {
|
| 572 |
+
for (const key of keys) {
|
| 573 |
+
const value = readEnvEntry(key);
|
| 574 |
+
if (value) return value;
|
| 575 |
+
}
|
| 576 |
+
return undefined;
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
function readFirstConfiguredServerBaseUrl() {
|
| 580 |
+
return readEnvEntry('OPENAI_API_BASE_URL') || readFirstNumberedEnvEntry('OPENAI_CHANNEL_', '_BASE_URL');
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
function readFirstConfiguredServerApiKeys() {
|
| 584 |
+
return env('OPENAI_API_KEY') || readFirstNumberedEnv('OPENAI_CHANNEL_', '_API_KEYS');
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
function readFirstNumberedEnv(prefix, suffix) {
|
| 588 |
+
for (let index = 1; index <= 20; index += 1) {
|
| 589 |
+
const value = env(`${prefix}${index}${suffix}`);
|
| 590 |
+
if (value) return value;
|
| 591 |
+
}
|
| 592 |
+
return undefined;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
function readFirstNumberedEnvEntry(prefix, suffix) {
|
| 596 |
+
for (let index = 1; index <= 20; index += 1) {
|
| 597 |
+
const value = readEnvEntry(`${prefix}${index}${suffix}`);
|
| 598 |
+
if (value) return value;
|
| 599 |
+
}
|
| 600 |
+
return undefined;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
function imageRequest(testCase, target, signal) {
|
| 604 |
+
const formData = new FormData();
|
| 605 |
+
const fields = {
|
| 606 |
+
mode: 'generate',
|
| 607 |
+
prompt: 'real upstream compatibility smoke',
|
| 608 |
+
model: target.model,
|
| 609 |
+
n: '1',
|
| 610 |
+
size: target.size,
|
| 611 |
+
quality: target.quality,
|
| 612 |
+
output_format: 'png',
|
| 613 |
+
clientRequestId: `real-smoke-${testCase.id}`
|
| 614 |
+
};
|
| 615 |
+
for (const [key, value] of Object.entries(fields)) formData.append(key, value);
|
| 616 |
+
if (!target.serverChannel) {
|
| 617 |
+
formData.append('apiBaseUrl', target.baseUrl);
|
| 618 |
+
formData.append('apiKey', target.apiKey);
|
| 619 |
+
}
|
| 620 |
+
const pagePasswordHash = readAppPasswordHash();
|
| 621 |
+
if (pagePasswordHash) formData.append('passwordHash', pagePasswordHash);
|
| 622 |
+
formData.append('imageBackend', testCase.backend || 'images-api');
|
| 623 |
+
formData.append('imageStreamingStrategy', testCase.strategy || 'off');
|
| 624 |
+
if (testCase.backend === 'responses-image-generation') formData.append('responsesModel', target.responsesModel);
|
| 625 |
+
if (testCase.stream) {
|
| 626 |
+
formData.append('stream', 'true');
|
| 627 |
+
formData.append('partial_images', '2');
|
| 628 |
+
}
|
| 629 |
+
return new Request('http://localhost/api/images', { method: 'POST', body: formData, signal });
|
| 630 |
+
}
|
| 631 |
+
|
| 632 |
+
function agentGenerateRequest(testCase, target, signal) {
|
| 633 |
+
return new Request('http://localhost/api/agent/images/generate', {
|
| 634 |
+
method: 'POST',
|
| 635 |
+
headers: buildAgentRequestHeaders(testCase),
|
| 636 |
+
body: JSON.stringify({
|
| 637 |
+
prompt: 'real agent upstream sse compatibility smoke',
|
| 638 |
+
model: target.model,
|
| 639 |
+
n: 1,
|
| 640 |
+
size: target.size,
|
| 641 |
+
quality: target.quality,
|
| 642 |
+
output_format: 'png',
|
| 643 |
+
response_mode: 'path',
|
| 644 |
+
image_backend: testCase.backend || 'images-api',
|
| 645 |
+
streaming_strategy: testCase.strategy || 'off',
|
| 646 |
+
partial_images: 2
|
| 647 |
+
}),
|
| 648 |
+
signal
|
| 649 |
+
});
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
function buildAgentRequestHeaders(testCase) {
|
| 653 |
+
return {
|
| 654 |
+
'Content-Type': 'application/json',
|
| 655 |
+
'Idempotency-Key': `real-smoke-${testCase.id}-${Date.now()}`,
|
| 656 |
+
...readAgentAuthHeaders()
|
| 657 |
+
};
|
| 658 |
+
}
|
| 659 |
+
|
| 660 |
+
function readAgentAuthHeaders() {
|
| 661 |
+
const token = env('AGENT_API_TOKEN');
|
| 662 |
+
if (token) return { Authorization: `Bearer ${token}` };
|
| 663 |
+
const passwordHash = readAppPasswordHash();
|
| 664 |
+
return passwordHash ? { 'X-App-Password-Hash': passwordHash } : {};
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
function readAppPasswordHash() {
|
| 668 |
+
const password = env('APP_PASSWORD');
|
| 669 |
+
if (!password) return undefined;
|
| 670 |
+
return crypto.createHash('sha256').update(password).digest('hex');
|
| 671 |
+
}
|
| 672 |
+
|
| 673 |
+
async function summarizeResponse(response) {
|
| 674 |
+
const contentType = response.headers.get('content-type') || '';
|
| 675 |
+
if (contentType.includes('text/event-stream')) return summarizeSse(await response.text());
|
| 676 |
+
return summarizeJson(await response.text(), contentType);
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
function summarizeSse(text) {
|
| 680 |
+
const events = text
|
| 681 |
+
.split('\n\n')
|
| 682 |
+
.map((part) => part.trim())
|
| 683 |
+
.filter((part) => part.startsWith('data: ') && part !== 'data: [DONE]')
|
| 684 |
+
.map((part) => safeJson(part.slice('data: '.length)))
|
| 685 |
+
.filter(Boolean);
|
| 686 |
+
const done = events.findLast((event) => event.type === 'done');
|
| 687 |
+
const error = events.findLast((event) => event.type === 'error');
|
| 688 |
+
return {
|
| 689 |
+
content_type: 'text/event-stream',
|
| 690 |
+
event_types: events.map((event) => event.type || 'unknown'),
|
| 691 |
+
done_image_count: Array.isArray(done?.images) ? done.images.length : 0,
|
| 692 |
+
first_b64_length: readFirstB64Length(done?.images),
|
| 693 |
+
...(error ? { error: String(error.error || 'stream error') } : {})
|
| 694 |
+
};
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
function isSuccessfulBillableSmokeResponse(response, summary) {
|
| 698 |
+
if (!response.ok || summary?.error) return false;
|
| 699 |
+
if (summary?.content_type === 'text/event-stream') {
|
| 700 |
+
return summary.done_image_count > 0;
|
| 701 |
+
}
|
| 702 |
+
if (typeof summary?.image_count === 'number') {
|
| 703 |
+
return summary.image_count > 0;
|
| 704 |
+
}
|
| 705 |
+
return false;
|
| 706 |
+
}
|
| 707 |
+
|
| 708 |
+
function summarizeJson(text, contentType) {
|
| 709 |
+
const body = safeJson(text);
|
| 710 |
+
return {
|
| 711 |
+
content_type: contentType || undefined,
|
| 712 |
+
image_count: Array.isArray(body?.images) ? body.images.length : 0,
|
| 713 |
+
first_b64_length: readFirstB64Length(body?.images),
|
| 714 |
+
...(body?.error ? { error: String(body.error) } : {})
|
| 715 |
+
};
|
| 716 |
+
}
|
| 717 |
+
|
| 718 |
+
async function summarizeAgentResponse(response) {
|
| 719 |
+
const contentType = response.headers.get('content-type') || '';
|
| 720 |
+
const body = safeJson(await response.text());
|
| 721 |
+
return {
|
| 722 |
+
content_type: contentType || undefined,
|
| 723 |
+
image_count: Array.isArray(body?.images) ? body.images.length : 0,
|
| 724 |
+
first_content_url: typeof body?.images?.[0]?.content_url === 'string' ? body.images[0].content_url : undefined,
|
| 725 |
+
has_inline_base64: Boolean(body?.images?.[0]?.b64_json),
|
| 726 |
+
...(body?.error ? { error: String(body.error.message || body.error) } : {})
|
| 727 |
+
};
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
function readFirstB64Length(images) {
|
| 731 |
+
if (!Array.isArray(images) || typeof images[0]?.b64_json !== 'string') return 0;
|
| 732 |
+
return images[0].b64_json.length;
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
function safeJson(text) {
|
| 736 |
+
try {
|
| 737 |
+
return JSON.parse(text);
|
| 738 |
+
} catch {
|
| 739 |
+
return undefined;
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
function readHost(value) {
|
| 744 |
+
try {
|
| 745 |
+
return new URL(value).host;
|
| 746 |
+
} catch {
|
| 747 |
+
return 'invalid-url';
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
function snapshotRealSmokeOutputFiles() {
|
| 752 |
+
const outputDir = readRealSmokeOutputDir();
|
| 753 |
+
if (!outputDir || !fs.existsSync(outputDir)) return new Set();
|
| 754 |
+
return new Set(fs.readdirSync(outputDir).filter(isGeneratedImageFile));
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
function removeNewRealSmokeOutputFiles(filesBefore) {
|
| 758 |
+
const outputDir = readRealSmokeOutputDir();
|
| 759 |
+
if (!outputDir || !fs.existsSync(outputDir)) return;
|
| 760 |
+
for (const filename of fs.readdirSync(outputDir).filter(isGeneratedImageFile)) {
|
| 761 |
+
if (filesBefore.has(filename)) continue;
|
| 762 |
+
fs.rmSync(`${outputDir}/${filename}`, { force: true });
|
| 763 |
+
}
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
function readRealSmokeOutputDir() {
|
| 767 |
+
return process.env.IMAGE_OUTPUT_DIR === 'generated-images/.real-smoke' ? process.env.IMAGE_OUTPUT_DIR : undefined;
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
function isGeneratedImageFile(filename) {
|
| 771 |
+
return /\.(png|jpe?g|webp)$/i.test(filename);
|
| 772 |
+
}
|
| 773 |
+
|
| 774 |
+
function printUsage() {
|
| 775 |
+
console.log(`用法:npm run smoke:image-upstream-real -- [--env-file <path>] [--env-file-if-exists <path>] [--allow-billable] [--include-server-channel] [--require-independent-targets] [--timeout-ms <ms>] [--case <id>]
|
| 776 |
+
|
| 777 |
+
环境变量前缀:
|
| 778 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL / IMAGE_REAL_SMOKE_ORIGINAL_API_KEY
|
| 779 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL / IMAGE_REAL_SMOKE_GAOREN_API_KEY
|
| 780 |
+
IMAGE_REAL_SMOKE_SUB2API_BASE_URL / IMAGE_REAL_SMOKE_SUB2API_API_KEY
|
| 781 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL / IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY
|
| 782 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL / IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY
|
| 783 |
+
IMAGE_REAL_SMOKE_SERVER_* 可覆盖当前服务端渠道的 MODEL / SIZE / QUALITY / RESPONSES_MODEL
|
| 784 |
+
|
| 785 |
+
可选 --case:all、original-images-json、gaoren-images-sse、sub2api-images-sse、sub2api-responses-json、gpt2image-responses-sse。
|
| 786 |
+
添加 --include-server-channel 后还可运行:server-channel-images-json、server-channel-images-sse、server-channel-responses-sse、server-channel-responses-json、server-channel-agent-images-sse、server-channel-agent-responses-sse。
|
| 787 |
+
默认只检查配置并跳过真实生图;必须加 --allow-billable 才会调用 /api/images 或 /api/agent/images/generate。
|
| 788 |
+
可用 --env-file 指向独立真实上游凭据文件;shell 环境变量优先级高于 --env-file,--env-file 优先级高于 .env.local。
|
| 789 |
+
可用 --env-file-if-exists 在凭据文件存在时加载,不存在时继续输出结构化 readiness 报告。
|
| 790 |
+
添加 --require-independent-targets 后,任何独立真实上游场景未被选中或被跳过都会使脚本退出非零。默认单场景超时为 240000ms。`);
|
| 791 |
+
}
|
scripts/smoke-image-upstream-real.test.mjs
ADDED
|
@@ -0,0 +1,1070 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawn, spawnSync } from 'node:child_process';
|
| 3 |
+
import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
| 4 |
+
import { createServer } from 'node:http';
|
| 5 |
+
import { dirname, join } from 'node:path';
|
| 6 |
+
import { describe, it } from 'node:test';
|
| 7 |
+
import { fileURLToPath } from 'node:url';
|
| 8 |
+
|
| 9 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 10 |
+
const scriptPath = join(repoRoot, 'scripts/smoke-image-upstream-real.mjs');
|
| 11 |
+
const realSmokeOutputDir = join(repoRoot, 'generated-images/.real-smoke');
|
| 12 |
+
const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
| 13 |
+
|
| 14 |
+
describe('image upstream real smoke script', () => {
|
| 15 |
+
it('skips every real upstream target without billable calls when no target env is configured', () => {
|
| 16 |
+
const result = runScript();
|
| 17 |
+
|
| 18 |
+
assert.equal(result.status, 0);
|
| 19 |
+
assert.equal(result.stderr.trim(), '');
|
| 20 |
+
const report = JSON.parse(result.stdout);
|
| 21 |
+
assert.equal(report.ok, true);
|
| 22 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 23 |
+
assert.equal(report.billable, false);
|
| 24 |
+
assert.equal(report.results.length, 5);
|
| 25 |
+
assert.equal(
|
| 26 |
+
report.results.every((item) => item.skipped === true),
|
| 27 |
+
true
|
| 28 |
+
);
|
| 29 |
+
assert.equal(
|
| 30 |
+
report.results.every((item) => item.reason === 'missing base url env'),
|
| 31 |
+
true
|
| 32 |
+
);
|
| 33 |
+
assert.deepEqual(report.results[0].missing_env_any, [['IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL']]);
|
| 34 |
+
assert.deepEqual(report.results[3].missing_env_any, [
|
| 35 |
+
['IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL', 'IMAGE_REAL_SMOKE_SUB2API_BASE_URL']
|
| 36 |
+
]);
|
| 37 |
+
assert.deepEqual(report.independent_targets, {
|
| 38 |
+
required_count: 5,
|
| 39 |
+
required_cases: independentSmokeCaseIds(),
|
| 40 |
+
selected_cases: independentSmokeCaseIds(),
|
| 41 |
+
unselected_required_count: 0,
|
| 42 |
+
unselected_required_cases: [],
|
| 43 |
+
configuration_complete: false,
|
| 44 |
+
selected_count: 5,
|
| 45 |
+
configured_count: 0,
|
| 46 |
+
missing_count: 5,
|
| 47 |
+
configured_cases: [],
|
| 48 |
+
missing_cases: independentSmokeCaseIds(),
|
| 49 |
+
invalid_count: 0,
|
| 50 |
+
invalid_cases: [],
|
| 51 |
+
invalid_env: {},
|
| 52 |
+
final_gate_command:
|
| 53 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable'
|
| 54 |
+
});
|
| 55 |
+
});
|
| 56 |
+
|
| 57 |
+
it('does not run configured real upstreams without explicit billable consent or leak API keys', () => {
|
| 58 |
+
const result = runScript(['--case', 'original-images-json'], {
|
| 59 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://example.test/v1',
|
| 60 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key'
|
| 61 |
+
});
|
| 62 |
+
|
| 63 |
+
assert.equal(result.status, 0);
|
| 64 |
+
assert.equal(result.stderr.trim(), '');
|
| 65 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key/);
|
| 66 |
+
const report = JSON.parse(result.stdout);
|
| 67 |
+
assert.equal(report.ok, true);
|
| 68 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 69 |
+
assert.equal(report.results.length, 1);
|
| 70 |
+
assert.equal(report.results[0].reason, 'requires --allow-billable');
|
| 71 |
+
assert.equal(report.results[0].upstream_host, 'example.test');
|
| 72 |
+
assert.equal('missing_env_any' in report.results[0], false);
|
| 73 |
+
assert.deepEqual(report.independent_targets.required_cases, independentSmokeCaseIds());
|
| 74 |
+
assert.deepEqual(report.independent_targets.selected_cases, ['original-images-json']);
|
| 75 |
+
assert.deepEqual(report.independent_targets.unselected_required_cases, [
|
| 76 |
+
'gaoren-images-sse',
|
| 77 |
+
'sub2api-images-sse',
|
| 78 |
+
'sub2api-responses-json',
|
| 79 |
+
'gpt2image-responses-sse'
|
| 80 |
+
]);
|
| 81 |
+
assert.equal(report.independent_targets.configuration_complete, false);
|
| 82 |
+
assert.deepEqual(report.independent_targets.configured_cases, ['original-images-json']);
|
| 83 |
+
assert.deepEqual(report.independent_targets.missing_cases, []);
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
it('loads independent real upstream targets from an explicit env file without leaking API keys', () => {
|
| 87 |
+
const envFilePath = join(repoRoot, 'generated-images/.real-smoke-test.env');
|
| 88 |
+
try {
|
| 89 |
+
writeEnvFile(
|
| 90 |
+
envFilePath,
|
| 91 |
+
[
|
| 92 |
+
'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://env-file.example/v1',
|
| 93 |
+
'IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=secret-env-file-key'
|
| 94 |
+
].join('\n')
|
| 95 |
+
);
|
| 96 |
+
const result = runScript(['--env-file', envFilePath, '--case', 'original-images-json']);
|
| 97 |
+
|
| 98 |
+
assert.equal(result.status, 0);
|
| 99 |
+
assert.equal(result.stderr.trim(), '');
|
| 100 |
+
assert.doesNotMatch(result.stdout, /secret-env-file-key/);
|
| 101 |
+
const report = JSON.parse(result.stdout);
|
| 102 |
+
assert.equal(report.results[0].reason, 'requires --allow-billable');
|
| 103 |
+
assert.equal(report.results[0].upstream_host, 'env-file.example');
|
| 104 |
+
} finally {
|
| 105 |
+
rmSync(envFilePath, { force: true });
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
it('keeps shell environment values ahead of explicit env file values', () => {
|
| 110 |
+
const envFilePath = join(repoRoot, 'generated-images/.real-smoke-test.env');
|
| 111 |
+
try {
|
| 112 |
+
writeEnvFile(
|
| 113 |
+
envFilePath,
|
| 114 |
+
[
|
| 115 |
+
'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://env-file.example/v1',
|
| 116 |
+
'IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=secret-env-file-key'
|
| 117 |
+
].join('\n')
|
| 118 |
+
);
|
| 119 |
+
const result = runScript(['--env-file', envFilePath, '--case', 'original-images-json'], {
|
| 120 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://shell.example/v1',
|
| 121 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-shell-key'
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
assert.equal(result.status, 0);
|
| 125 |
+
assert.doesNotMatch(result.stdout, /secret-env-file-key|secret-shell-key/);
|
| 126 |
+
const report = JSON.parse(result.stdout);
|
| 127 |
+
assert.equal(report.results[0].upstream_host, 'shell.example');
|
| 128 |
+
} finally {
|
| 129 |
+
rmSync(envFilePath, { force: true });
|
| 130 |
+
}
|
| 131 |
+
});
|
| 132 |
+
|
| 133 |
+
it('reports the missing API key env when a real upstream base URL is configured alone', () => {
|
| 134 |
+
const result = runScript(['--case', 'gaoren-images-sse'], {
|
| 135 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL: 'https://gaoren.example/v1'
|
| 136 |
+
});
|
| 137 |
+
|
| 138 |
+
assert.equal(result.status, 0);
|
| 139 |
+
const report = JSON.parse(result.stdout);
|
| 140 |
+
assert.equal(report.results[0].reason, 'missing api key env');
|
| 141 |
+
assert.deepEqual(report.results[0].missing_env_any, [['IMAGE_REAL_SMOKE_GAOREN_API_KEY']]);
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
it('reports unsafe real upstream base URLs in the structured readiness summary', () => {
|
| 145 |
+
const result = runScript(['--case', 'original-images-json'], {
|
| 146 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@example.test/v1?token=secret#frag',
|
| 147 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key'
|
| 148 |
+
});
|
| 149 |
+
|
| 150 |
+
assert.equal(result.status, 1);
|
| 151 |
+
assert.equal(result.stderr.trim(), '');
|
| 152 |
+
assert.doesNotMatch(result.stdout, /user:pass|example\.test|secret-real-smoke-key|token=secret/);
|
| 153 |
+
const report = JSON.parse(result.stdout);
|
| 154 |
+
assert.equal(report.ok, false);
|
| 155 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 156 |
+
assert.equal(report.independent_targets.configuration_complete, false);
|
| 157 |
+
assert.equal(report.independent_targets.configured_count, 0);
|
| 158 |
+
assert.equal(report.independent_targets.invalid_count, 1);
|
| 159 |
+
assert.deepEqual(report.independent_targets.invalid_cases, ['original-images-json']);
|
| 160 |
+
assert.deepEqual(report.independent_targets.invalid_env['original-images-json'], [
|
| 161 |
+
{
|
| 162 |
+
key: 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL',
|
| 163 |
+
reason: 'must_not_include_credentials'
|
| 164 |
+
}
|
| 165 |
+
]);
|
| 166 |
+
assert.deepEqual(report.results[0].invalid_env, [
|
| 167 |
+
{
|
| 168 |
+
key: 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL',
|
| 169 |
+
reason: 'must_not_include_credentials'
|
| 170 |
+
}
|
| 171 |
+
]);
|
| 172 |
+
});
|
| 173 |
+
|
| 174 |
+
it('does not make billable upstream calls when any selected target has unsafe configuration', async () => {
|
| 175 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 176 |
+
try {
|
| 177 |
+
const result = await runScriptAsync(['--allow-billable', '--require-independent-targets'], {
|
| 178 |
+
...buildAllIndependentTargetEnv(upstream.baseUrl),
|
| 179 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@example.test/v1?token=secret#frag'
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
assert.equal(result.status, 1);
|
| 183 |
+
assert.doesNotMatch(result.stdout, /user:pass|example\.test|secret-independent-key|token=secret/);
|
| 184 |
+
const report = JSON.parse(result.stdout);
|
| 185 |
+
assert.equal(report.ok, false);
|
| 186 |
+
assert.deepEqual(report.invalid_required_cases, ['original-images-json']);
|
| 187 |
+
assert.deepEqual(report.blocked_required_cases, [
|
| 188 |
+
'gaoren-images-sse',
|
| 189 |
+
'sub2api-images-sse',
|
| 190 |
+
'sub2api-responses-json',
|
| 191 |
+
'gpt2image-responses-sse'
|
| 192 |
+
]);
|
| 193 |
+
assert.equal(upstream.calls.length, 0);
|
| 194 |
+
} finally {
|
| 195 |
+
await upstream.close();
|
| 196 |
+
}
|
| 197 |
+
});
|
| 198 |
+
|
| 199 |
+
it('reports blocked cases at the top level for non-final-gate billable smoke runs', async () => {
|
| 200 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 201 |
+
try {
|
| 202 |
+
const result = await runScriptAsync(['--allow-billable'], {
|
| 203 |
+
...buildAllIndependentTargetEnv(upstream.baseUrl),
|
| 204 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@example.test/v1?token=secret#frag'
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
assert.equal(result.status, 1);
|
| 208 |
+
const report = JSON.parse(result.stdout);
|
| 209 |
+
assert.deepEqual(report.blocked_cases, [
|
| 210 |
+
'gaoren-images-sse',
|
| 211 |
+
'sub2api-images-sse',
|
| 212 |
+
'sub2api-responses-json',
|
| 213 |
+
'gpt2image-responses-sse'
|
| 214 |
+
]);
|
| 215 |
+
assert.equal(upstream.calls.length, 0);
|
| 216 |
+
} finally {
|
| 217 |
+
await upstream.close();
|
| 218 |
+
}
|
| 219 |
+
});
|
| 220 |
+
|
| 221 |
+
it('does not make billable upstream calls when the required independent gate is missing target config', async () => {
|
| 222 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 223 |
+
try {
|
| 224 |
+
const result = await runScriptAsync(['--allow-billable', '--require-independent-targets'], {
|
| 225 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl,
|
| 226 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key'
|
| 227 |
+
});
|
| 228 |
+
|
| 229 |
+
assert.equal(result.status, 1);
|
| 230 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key/);
|
| 231 |
+
const report = JSON.parse(result.stdout);
|
| 232 |
+
assert.equal(report.ok, false);
|
| 233 |
+
assert.deepEqual(report.blocked_required_cases, ['original-images-json']);
|
| 234 |
+
assert.deepEqual(report.skipped_required_cases, [
|
| 235 |
+
'gaoren-images-sse',
|
| 236 |
+
'sub2api-images-sse',
|
| 237 |
+
'sub2api-responses-json',
|
| 238 |
+
'gpt2image-responses-sse'
|
| 239 |
+
]);
|
| 240 |
+
assert.equal(upstream.calls.length, 0);
|
| 241 |
+
} finally {
|
| 242 |
+
await upstream.close();
|
| 243 |
+
}
|
| 244 |
+
});
|
| 245 |
+
|
| 246 |
+
it('fails the run when independent upstream targets are required but skipped', () => {
|
| 247 |
+
const result = runScript(['--require-independent-targets']);
|
| 248 |
+
|
| 249 |
+
assert.equal(result.status, 1);
|
| 250 |
+
assert.equal(result.stderr.trim(), '');
|
| 251 |
+
const report = JSON.parse(result.stdout);
|
| 252 |
+
assert.equal(report.ok, false);
|
| 253 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 254 |
+
assert.deepEqual(report.skipped_required_cases, [
|
| 255 |
+
'original-images-json',
|
| 256 |
+
'gaoren-images-sse',
|
| 257 |
+
'sub2api-images-sse',
|
| 258 |
+
'sub2api-responses-json',
|
| 259 |
+
'gpt2image-responses-sse'
|
| 260 |
+
]);
|
| 261 |
+
assert.equal('unselected_required_cases' in report, false);
|
| 262 |
+
assert.equal(report.missing_required_count, 5);
|
| 263 |
+
assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds());
|
| 264 |
+
});
|
| 265 |
+
|
| 266 |
+
it('fails the required independent upstream gate when billable consent is missing', () => {
|
| 267 |
+
const result = runScript(['--require-independent-targets', '--case', 'original-images-json'], {
|
| 268 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://example.test/v1',
|
| 269 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key'
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
assert.equal(result.status, 1);
|
| 273 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key/);
|
| 274 |
+
const report = JSON.parse(result.stdout);
|
| 275 |
+
assert.equal(report.ok, false);
|
| 276 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 277 |
+
assert.deepEqual(report.skipped_required_cases, ['original-images-json']);
|
| 278 |
+
assert.deepEqual(report.unselected_required_cases, [
|
| 279 |
+
'gaoren-images-sse',
|
| 280 |
+
'sub2api-images-sse',
|
| 281 |
+
'sub2api-responses-json',
|
| 282 |
+
'gpt2image-responses-sse'
|
| 283 |
+
]);
|
| 284 |
+
assert.equal(report.missing_required_count, 5);
|
| 285 |
+
assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds());
|
| 286 |
+
assert.equal(report.results[0].reason, 'requires --allow-billable');
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
it('can include current server channel smoke cases without explicit billable consent or leaking keys', () => {
|
| 290 |
+
const result = runScript(['--include-server-channel', '--case', 'server-channel-agent-images-sse'], {
|
| 291 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1',
|
| 292 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key'
|
| 293 |
+
});
|
| 294 |
+
|
| 295 |
+
assert.equal(result.status, 0);
|
| 296 |
+
assert.equal(result.stderr.trim(), '');
|
| 297 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key/);
|
| 298 |
+
const report = JSON.parse(result.stdout);
|
| 299 |
+
assert.equal(report.ok, true);
|
| 300 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 301 |
+
assert.equal(report.results.length, 1);
|
| 302 |
+
assert.equal(report.results[0].reason, 'requires --allow-billable');
|
| 303 |
+
assert.equal(report.results[0].server_channel, true);
|
| 304 |
+
assert.equal(report.results[0].upstream_host, 'server-channel.example');
|
| 305 |
+
assert.equal('independent_targets' in report, false);
|
| 306 |
+
});
|
| 307 |
+
|
| 308 |
+
it('fails the required independent upstream gate when only server channel cases are selected', () => {
|
| 309 |
+
const result = runScript(
|
| 310 |
+
['--include-server-channel', '--require-independent-targets', '--case', 'server-channel-agent-images-sse'],
|
| 311 |
+
{
|
| 312 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1',
|
| 313 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key'
|
| 314 |
+
}
|
| 315 |
+
);
|
| 316 |
+
|
| 317 |
+
assert.equal(result.status, 1);
|
| 318 |
+
assert.equal(result.stderr.trim(), '');
|
| 319 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key/);
|
| 320 |
+
const report = JSON.parse(result.stdout);
|
| 321 |
+
assert.equal(report.ok, false);
|
| 322 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 323 |
+
assert.equal(report.independent_targets.required_count, 5);
|
| 324 |
+
assert.deepEqual(report.independent_targets.selected_cases, []);
|
| 325 |
+
assert.equal(report.independent_targets.unselected_required_count, 5);
|
| 326 |
+
assert.deepEqual(report.independent_targets.unselected_required_cases, independentSmokeCaseIds());
|
| 327 |
+
assert.deepEqual(report.unselected_required_cases, independentSmokeCaseIds());
|
| 328 |
+
assert.equal(report.missing_required_count, 5);
|
| 329 |
+
assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds());
|
| 330 |
+
assert.equal('skipped_required_cases' in report, false);
|
| 331 |
+
});
|
| 332 |
+
|
| 333 |
+
it('can dry-run the current server channel Agent Responses SSE smoke case without leaking keys', () => {
|
| 334 |
+
const result = runScript(['--include-server-channel', '--case', 'server-channel-agent-responses-sse'], {
|
| 335 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1',
|
| 336 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 337 |
+
IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-4.1'
|
| 338 |
+
});
|
| 339 |
+
|
| 340 |
+
assert.equal(result.status, 0);
|
| 341 |
+
assert.equal(result.stderr.trim(), '');
|
| 342 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key/);
|
| 343 |
+
const report = JSON.parse(result.stdout);
|
| 344 |
+
assert.equal(report.ok, true);
|
| 345 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 346 |
+
assert.equal(report.results.length, 1);
|
| 347 |
+
assert.equal(report.results[0].reason, 'requires --allow-billable');
|
| 348 |
+
assert.equal(report.results[0].server_channel, true);
|
| 349 |
+
assert.equal(report.results[0].upstream_host, 'server-channel.example');
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
it('can dry-run the current server channel Responses JSON smoke case without leaking keys', () => {
|
| 353 |
+
const result = runScript(['--include-server-channel', '--case', 'server-channel-responses-json'], {
|
| 354 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1',
|
| 355 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 356 |
+
IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-4.1'
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
assert.equal(result.status, 0);
|
| 360 |
+
assert.equal(result.stderr.trim(), '');
|
| 361 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key/);
|
| 362 |
+
const report = JSON.parse(result.stdout);
|
| 363 |
+
assert.equal(report.ok, true);
|
| 364 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 365 |
+
assert.equal(report.results.length, 1);
|
| 366 |
+
assert.equal(report.results[0].reason, 'requires --allow-billable');
|
| 367 |
+
assert.equal(report.results[0].server_channel, true);
|
| 368 |
+
assert.equal(report.results[0].upstream_host, 'server-channel.example');
|
| 369 |
+
});
|
| 370 |
+
|
| 371 |
+
it('requires an explicit Responses top-level model for Responses smoke readiness', () => {
|
| 372 |
+
const result = runScript(['--case', 'sub2api-responses-json'], {
|
| 373 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: 'https://responses.example/v1',
|
| 374 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'secret-responses-key'
|
| 375 |
+
});
|
| 376 |
+
|
| 377 |
+
assert.equal(result.status, 0);
|
| 378 |
+
assert.doesNotMatch(result.stdout, /secret-responses-key|responses\.example/);
|
| 379 |
+
const report = JSON.parse(result.stdout);
|
| 380 |
+
assert.equal(report.ok, true);
|
| 381 |
+
assert.equal(report.results[0].reason, 'missing responses model env');
|
| 382 |
+
assert.deepEqual(report.results[0].missing_env_any, [
|
| 383 |
+
['IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL', 'OPENAI_RESPONSES_API_MODEL']
|
| 384 |
+
]);
|
| 385 |
+
assert.equal(report.independent_targets.configuration_complete, false);
|
| 386 |
+
assert.deepEqual(report.independent_targets.missing_cases, ['sub2api-responses-json']);
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
it('does not treat the sub2api Responses image model env as readiness for the Responses top-level model', () => {
|
| 390 |
+
const result = runScript(['--case', 'sub2api-responses-json'], {
|
| 391 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: 'https://responses.example/v1',
|
| 392 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'secret-responses-key',
|
| 393 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL: 'gpt-image-2'
|
| 394 |
+
});
|
| 395 |
+
|
| 396 |
+
assert.equal(result.status, 0);
|
| 397 |
+
assert.doesNotMatch(result.stdout, /secret-responses-key|responses\.example/);
|
| 398 |
+
const report = JSON.parse(result.stdout);
|
| 399 |
+
assert.equal(report.ok, true);
|
| 400 |
+
assert.equal(report.results[0].reason, 'missing responses model env');
|
| 401 |
+
assert.deepEqual(report.results[0].missing_env_any, [
|
| 402 |
+
['IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL', 'OPENAI_RESPONSES_API_MODEL']
|
| 403 |
+
]);
|
| 404 |
+
assert.equal(report.independent_targets.configuration_complete, false);
|
| 405 |
+
assert.deepEqual(report.independent_targets.missing_cases, ['sub2api-responses-json']);
|
| 406 |
+
});
|
| 407 |
+
|
| 408 |
+
it('passes APP_PASSWORD auth to in-process page route billable smoke runs', async () => {
|
| 409 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 410 |
+
try {
|
| 411 |
+
const result = await runScriptAsync(['--allow-billable', '--case', 'original-images-json'], {
|
| 412 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl,
|
| 413 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key',
|
| 414 |
+
APP_PASSWORD: 'page-access-code'
|
| 415 |
+
});
|
| 416 |
+
|
| 417 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 418 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key|page-access-code/);
|
| 419 |
+
const report = JSON.parse(result.stdout);
|
| 420 |
+
assert.equal(report.results[0].status, 200);
|
| 421 |
+
assert.equal(report.results[0].image_count, 1);
|
| 422 |
+
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 423 |
+
} finally {
|
| 424 |
+
await upstream.close();
|
| 425 |
+
}
|
| 426 |
+
});
|
| 427 |
+
|
| 428 |
+
it('pins page route smoke backend and strategy against global route defaults', async () => {
|
| 429 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 430 |
+
try {
|
| 431 |
+
const result = await runScriptAsync(['--allow-billable', '--case', 'original-images-json'], {
|
| 432 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl,
|
| 433 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key',
|
| 434 |
+
IMAGE_GENERATION_BACKEND: 'responses-image-generation',
|
| 435 |
+
IMAGE_STREAMING_STRATEGY: 'force-sse',
|
| 436 |
+
ENABLE_RESPONSES_IMAGE_BACKEND: 'true',
|
| 437 |
+
OPENAI_RESPONSES_API_MODEL: 'gpt-4.1-env'
|
| 438 |
+
});
|
| 439 |
+
|
| 440 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 441 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key|gpt-4\.1-env/);
|
| 442 |
+
const report = JSON.parse(result.stdout);
|
| 443 |
+
assert.equal(report.results[0].status, 200);
|
| 444 |
+
assert.equal(report.results[0].content_type, 'application/json');
|
| 445 |
+
assert.equal(report.results[0].image_count, 1);
|
| 446 |
+
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 447 |
+
} finally {
|
| 448 |
+
await upstream.close();
|
| 449 |
+
}
|
| 450 |
+
});
|
| 451 |
+
|
| 452 |
+
it('trims APP_PASSWORD before hashing page route billable smoke auth', async () => {
|
| 453 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 454 |
+
try {
|
| 455 |
+
const result = await runScriptAsync(['--allow-billable', '--case', 'original-images-json'], {
|
| 456 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl,
|
| 457 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key',
|
| 458 |
+
APP_PASSWORD: ' page-access-code '
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 462 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key|page-access-code/);
|
| 463 |
+
const report = JSON.parse(result.stdout);
|
| 464 |
+
assert.equal(report.results[0].status, 200);
|
| 465 |
+
assert.equal(report.results[0].image_count, 1);
|
| 466 |
+
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 467 |
+
} finally {
|
| 468 |
+
await upstream.close();
|
| 469 |
+
}
|
| 470 |
+
});
|
| 471 |
+
|
| 472 |
+
it('passes AGENT_API_TOKEN auth to in-process Agent route billable smoke runs', async () => {
|
| 473 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 474 |
+
try {
|
| 475 |
+
const result = await runScriptAsync(
|
| 476 |
+
['--include-server-channel', '--allow-billable', '--case', 'server-channel-agent-images-sse'],
|
| 477 |
+
{
|
| 478 |
+
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 479 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 480 |
+
AGENT_API_TOKEN: 'secret-agent-token'
|
| 481 |
+
}
|
| 482 |
+
);
|
| 483 |
+
|
| 484 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 485 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key|secret-agent-token/);
|
| 486 |
+
const report = JSON.parse(result.stdout);
|
| 487 |
+
assert.equal(report.results[0].status, 200);
|
| 488 |
+
assert.equal(report.results[0].image_count, 1);
|
| 489 |
+
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 490 |
+
} finally {
|
| 491 |
+
await upstream.close();
|
| 492 |
+
}
|
| 493 |
+
});
|
| 494 |
+
|
| 495 |
+
it('passes APP_PASSWORD auth to in-process Agent route billable smoke runs when no Agent token is configured', async () => {
|
| 496 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 497 |
+
try {
|
| 498 |
+
const result = await runScriptAsync(
|
| 499 |
+
['--include-server-channel', '--allow-billable', '--case', 'server-channel-agent-images-sse'],
|
| 500 |
+
{
|
| 501 |
+
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 502 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 503 |
+
APP_PASSWORD: 'page-access-code'
|
| 504 |
+
}
|
| 505 |
+
);
|
| 506 |
+
|
| 507 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 508 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key|page-access-code/);
|
| 509 |
+
const report = JSON.parse(result.stdout);
|
| 510 |
+
assert.equal(report.results[0].status, 200);
|
| 511 |
+
assert.equal(report.results[0].image_count, 1);
|
| 512 |
+
assert.deepEqual(upstream.calls, ['/v1/images/generations']);
|
| 513 |
+
} finally {
|
| 514 |
+
await upstream.close();
|
| 515 |
+
}
|
| 516 |
+
});
|
| 517 |
+
|
| 518 |
+
it('removes generated .real-smoke artifact files after billable local Agent smoke runs', async () => {
|
| 519 |
+
const upstream = await startLocalImagesSseUpstream();
|
| 520 |
+
const before = listRealSmokeFiles();
|
| 521 |
+
let after = before;
|
| 522 |
+
try {
|
| 523 |
+
const result = await runScriptAsync(
|
| 524 |
+
['--include-server-channel', '--allow-billable', '--case', 'server-channel-agent-images-sse'],
|
| 525 |
+
{
|
| 526 |
+
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 527 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key'
|
| 528 |
+
}
|
| 529 |
+
);
|
| 530 |
+
|
| 531 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 532 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key/);
|
| 533 |
+
const report = JSON.parse(result.stdout);
|
| 534 |
+
assert.equal(report.ok, true);
|
| 535 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 536 |
+
assert.equal(report.results[0].image_count, 1);
|
| 537 |
+
after = listRealSmokeFiles();
|
| 538 |
+
assert.deepEqual(diffFiles(before, after), []);
|
| 539 |
+
} finally {
|
| 540 |
+
for (const file of diffFiles(before, after)) {
|
| 541 |
+
rmSync(join(realSmokeOutputDir, file), { force: true });
|
| 542 |
+
}
|
| 543 |
+
await upstream.close();
|
| 544 |
+
}
|
| 545 |
+
});
|
| 546 |
+
|
| 547 |
+
it('runs the current server channel Responses JSON smoke case against a local upstream', async () => {
|
| 548 |
+
const upstream = await startLocalResponsesJsonUpstream();
|
| 549 |
+
try {
|
| 550 |
+
const result = await runScriptAsync(
|
| 551 |
+
['--include-server-channel', '--allow-billable', '--case', 'server-channel-responses-json'],
|
| 552 |
+
{
|
| 553 |
+
OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl,
|
| 554 |
+
OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key',
|
| 555 |
+
IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-5.4'
|
| 556 |
+
}
|
| 557 |
+
);
|
| 558 |
+
|
| 559 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 560 |
+
assert.doesNotMatch(result.stdout, /secret-server-channel-key/);
|
| 561 |
+
const report = JSON.parse(result.stdout);
|
| 562 |
+
assert.equal(report.ok, true);
|
| 563 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 564 |
+
assert.equal(report.results[0].status, 200);
|
| 565 |
+
assert.equal(report.results[0].image_count, 1);
|
| 566 |
+
assert.equal(report.results[0].first_b64_length, pngBase64.length);
|
| 567 |
+
} finally {
|
| 568 |
+
await upstream.close();
|
| 569 |
+
}
|
| 570 |
+
});
|
| 571 |
+
|
| 572 |
+
it('marks the final gate satisfied only after every independent target runs successfully', async () => {
|
| 573 |
+
const upstream = await startLocalImageAndResponsesUpstream();
|
| 574 |
+
try {
|
| 575 |
+
const result = await runScriptAsync(
|
| 576 |
+
['--allow-billable', '--require-independent-targets'],
|
| 577 |
+
buildAllIndependentTargetEnv(upstream.baseUrl)
|
| 578 |
+
);
|
| 579 |
+
|
| 580 |
+
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 581 |
+
assert.doesNotMatch(result.stdout, /secret-independent-key/);
|
| 582 |
+
const report = JSON.parse(result.stdout);
|
| 583 |
+
assert.equal(report.ok, true);
|
| 584 |
+
assert.equal(report.final_gate_satisfied, true);
|
| 585 |
+
assert.equal(report.independent_targets.configuration_complete, true);
|
| 586 |
+
assert.equal(report.independent_targets.configured_count, 5);
|
| 587 |
+
assert.equal(report.results.length, 5);
|
| 588 |
+
assert.equal(
|
| 589 |
+
report.results.every((item) => item.ok === true && item.skipped !== true),
|
| 590 |
+
true
|
| 591 |
+
);
|
| 592 |
+
assert.equal('missing_required_cases' in report, false);
|
| 593 |
+
} finally {
|
| 594 |
+
await upstream.close();
|
| 595 |
+
}
|
| 596 |
+
});
|
| 597 |
+
|
| 598 |
+
it('fails the final gate when a page SSE smoke case returns a stream error event', async () => {
|
| 599 |
+
const successUpstream = await startLocalImageAndResponsesUpstream();
|
| 600 |
+
const failingSseUpstream = await startLocalFailingImagesSseUpstream();
|
| 601 |
+
try {
|
| 602 |
+
const result = await runScriptAsync(['--allow-billable', '--require-independent-targets'], {
|
| 603 |
+
...buildAllIndependentTargetEnv(successUpstream.baseUrl),
|
| 604 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL: failingSseUpstream.baseUrl,
|
| 605 |
+
IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'secret-independent-key-gaoren'
|
| 606 |
+
});
|
| 607 |
+
|
| 608 |
+
assert.equal(result.status, 1, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 609 |
+
assert.doesNotMatch(result.stdout, /secret-independent-key/);
|
| 610 |
+
const report = JSON.parse(result.stdout);
|
| 611 |
+
const failedCase = report.results.find((item) => item.id === 'gaoren-images-sse');
|
| 612 |
+
|
| 613 |
+
assert.equal(report.ok, false);
|
| 614 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 615 |
+
assert.equal(failedCase?.ok, false);
|
| 616 |
+
assert.equal(failedCase?.status, 200);
|
| 617 |
+
assert.equal(failedCase?.done_image_count, 0);
|
| 618 |
+
assert.match(failedCase?.error || '', /b64_json|最终图片/);
|
| 619 |
+
assert.equal(successUpstream.calls.length, 4);
|
| 620 |
+
} finally {
|
| 621 |
+
await successUpstream.close();
|
| 622 |
+
await failingSseUpstream.close();
|
| 623 |
+
}
|
| 624 |
+
});
|
| 625 |
+
|
| 626 |
+
it('exits after its own timeout when a billable upstream never responds', async () => {
|
| 627 |
+
const upstream = await startLocalHangingImageUpstream();
|
| 628 |
+
try {
|
| 629 |
+
const result = await runScriptAsync(
|
| 630 |
+
['--allow-billable', '--case', 'original-images-json', '--timeout-ms', '1000'],
|
| 631 |
+
{
|
| 632 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl,
|
| 633 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key'
|
| 634 |
+
},
|
| 635 |
+
{ killAfterMs: 4500 }
|
| 636 |
+
);
|
| 637 |
+
|
| 638 |
+
assert.equal(result.signal, null, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 639 |
+
assert.equal(result.killedByTest, false, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 640 |
+
assert.equal(result.status, 1, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 641 |
+
assert.doesNotMatch(result.stdout, /secret-real-smoke-key/);
|
| 642 |
+
const report = JSON.parse(result.stdout);
|
| 643 |
+
assert.equal(report.ok, false);
|
| 644 |
+
assert.equal(report.final_gate_satisfied, false);
|
| 645 |
+
assert.equal(report.results[0].timed_out, true);
|
| 646 |
+
assert.equal(report.results[0].ok, false);
|
| 647 |
+
assert.match(report.results[0].error, /timed out after 1000ms/);
|
| 648 |
+
} finally {
|
| 649 |
+
await upstream.close();
|
| 650 |
+
}
|
| 651 |
+
});
|
| 652 |
+
|
| 653 |
+
it('rejects invalid timeout values before running smoke cases', () => {
|
| 654 |
+
const result = runScript(['--timeout-ms', '999']);
|
| 655 |
+
|
| 656 |
+
assert.equal(result.status, 1);
|
| 657 |
+
assert.match(result.stderr, /--timeout-ms 必须是不小于 1000 的整数毫秒/);
|
| 658 |
+
});
|
| 659 |
+
|
| 660 |
+
it('rejects missing env file values before running smoke cases', () => {
|
| 661 |
+
const result = runScript(['--env-file']);
|
| 662 |
+
|
| 663 |
+
assert.equal(result.status, 1);
|
| 664 |
+
assert.match(result.stderr, /--env-file 缺少参数值/);
|
| 665 |
+
});
|
| 666 |
+
|
| 667 |
+
it('lets the npm smoke script pass --env-file through to the smoke script', () => {
|
| 668 |
+
const missingEnvFilePath = join(repoRoot, 'generated-images/.missing-real-smoke.env');
|
| 669 |
+
rmSync(missingEnvFilePath, { force: true });
|
| 670 |
+
|
| 671 |
+
const result = spawnSync('npm', ['run', 'smoke:image-upstream-real', '--', '--env-file', missingEnvFilePath], {
|
| 672 |
+
cwd: repoRoot,
|
| 673 |
+
encoding: 'utf8',
|
| 674 |
+
env: buildScriptEnv()
|
| 675 |
+
});
|
| 676 |
+
|
| 677 |
+
assert.equal(result.status, 1);
|
| 678 |
+
assert.match(result.stderr, /--env-file 指定的文件不存在/);
|
| 679 |
+
assert.doesNotMatch(result.stderr, /node: .*not found/);
|
| 680 |
+
assert.doesNotMatch(result.stderr, /ModuleJob\.run|Node\.js v|at loadEnvFile/);
|
| 681 |
+
});
|
| 682 |
+
|
| 683 |
+
it('lets the npm final gate report readiness when the optional env file is absent', () => {
|
| 684 |
+
const missingEnvFilePath = join(repoRoot, 'generated-images/.missing-real-smoke.env');
|
| 685 |
+
rmSync(missingEnvFilePath, { force: true });
|
| 686 |
+
|
| 687 |
+
const result = spawnSync(
|
| 688 |
+
'npm',
|
| 689 |
+
[
|
| 690 |
+
'run',
|
| 691 |
+
'smoke:image-upstream-real',
|
| 692 |
+
'--',
|
| 693 |
+
'--env-file-if-exists',
|
| 694 |
+
missingEnvFilePath,
|
| 695 |
+
'--require-independent-targets',
|
| 696 |
+
'--allow-billable'
|
| 697 |
+
],
|
| 698 |
+
{
|
| 699 |
+
cwd: repoRoot,
|
| 700 |
+
encoding: 'utf8',
|
| 701 |
+
env: buildScriptEnv()
|
| 702 |
+
}
|
| 703 |
+
);
|
| 704 |
+
|
| 705 |
+
assert.equal(result.status, 1);
|
| 706 |
+
assert.equal(result.stderr.trim(), '');
|
| 707 |
+
const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{')));
|
| 708 |
+
assert.equal(report.independent_targets.configuration_complete, false);
|
| 709 |
+
assert.equal(report.missing_required_count, 5);
|
| 710 |
+
assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds());
|
| 711 |
+
});
|
| 712 |
+
|
| 713 |
+
it('fails explicitly for unknown real upstream smoke cases', () => {
|
| 714 |
+
const result = runScript(['--case', 'missing-case']);
|
| 715 |
+
|
| 716 |
+
assert.equal(result.status, 1);
|
| 717 |
+
assert.match(result.stderr, /未知真实 smoke 场景:missing-case/);
|
| 718 |
+
});
|
| 719 |
+
|
| 720 |
+
it('lists every independent real upstream smoke target in help output', () => {
|
| 721 |
+
const result = runScript(['--help']);
|
| 722 |
+
|
| 723 |
+
assert.equal(result.status, 0);
|
| 724 |
+
assert.equal(result.stderr.trim(), '');
|
| 725 |
+
assert.match(result.stdout, /--env-file <path>/);
|
| 726 |
+
assert.match(result.stdout, /--env-file-if-exists <path>/);
|
| 727 |
+
for (const prefix of independentSmokePrefixes()) {
|
| 728 |
+
assert.match(result.stdout, new RegExp(`${prefix}_BASE_URL / ${prefix}_API_KEY`));
|
| 729 |
+
}
|
| 730 |
+
});
|
| 731 |
+
|
| 732 |
+
it('documents every independent real upstream smoke target in env templates without secrets', () => {
|
| 733 |
+
const envExample = readFileSync(join(repoRoot, '.env.example'), 'utf8');
|
| 734 |
+
const realSmokeEnvExample = readFileSync(join(repoRoot, '.env.real-smoke.example'), 'utf8');
|
| 735 |
+
const gitignore = readFileSync(join(repoRoot, '.gitignore'), 'utf8');
|
| 736 |
+
|
| 737 |
+
for (const prefix of independentSmokePrefixes()) {
|
| 738 |
+
assert.match(envExample, new RegExp(`# ${prefix}_BASE_URL=`));
|
| 739 |
+
assert.match(envExample, new RegExp(`# ${prefix}_API_KEY=$`, 'm'));
|
| 740 |
+
assert.match(realSmokeEnvExample, new RegExp(`${prefix}_BASE_URL=`));
|
| 741 |
+
assert.match(realSmokeEnvExample, new RegExp(`${prefix}_API_KEY=$`, 'm'));
|
| 742 |
+
}
|
| 743 |
+
assert.match(envExample, /# IMAGE_REAL_SMOKE_TIMEOUT_MS=240000/);
|
| 744 |
+
assert.match(realSmokeEnvExample, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
|
| 745 |
+
assert.match(gitignore, /^!.env\.real-smoke\.example$/m);
|
| 746 |
+
});
|
| 747 |
+
});
|
| 748 |
+
|
| 749 |
+
function independentSmokePrefixes() {
|
| 750 |
+
return [
|
| 751 |
+
'IMAGE_REAL_SMOKE_ORIGINAL',
|
| 752 |
+
'IMAGE_REAL_SMOKE_GAOREN',
|
| 753 |
+
'IMAGE_REAL_SMOKE_SUB2API',
|
| 754 |
+
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES',
|
| 755 |
+
'IMAGE_REAL_SMOKE_GPT2IMAGE'
|
| 756 |
+
];
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
function independentSmokeCaseIds() {
|
| 760 |
+
return [
|
| 761 |
+
'original-images-json',
|
| 762 |
+
'gaoren-images-sse',
|
| 763 |
+
'sub2api-images-sse',
|
| 764 |
+
'sub2api-responses-json',
|
| 765 |
+
'gpt2image-responses-sse'
|
| 766 |
+
];
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
function buildAllIndependentTargetEnv(baseUrl) {
|
| 770 |
+
return {
|
| 771 |
+
IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: baseUrl,
|
| 772 |
+
IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-independent-key-original',
|
| 773 |
+
IMAGE_REAL_SMOKE_GAOREN_BASE_URL: baseUrl,
|
| 774 |
+
IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'secret-independent-key-gaoren',
|
| 775 |
+
IMAGE_REAL_SMOKE_SUB2API_BASE_URL: baseUrl,
|
| 776 |
+
IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'secret-independent-key-sub2api',
|
| 777 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: baseUrl,
|
| 778 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'secret-independent-key-sub2api-responses',
|
| 779 |
+
IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL: 'gpt-4.1',
|
| 780 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: baseUrl,
|
| 781 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'secret-independent-key-gpt2image',
|
| 782 |
+
IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL: 'gpt-5.4'
|
| 783 |
+
};
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
function runScript(args = [], env = {}) {
|
| 787 |
+
return spawnSync(process.execPath, ['--import', 'tsx', scriptPath, ...args], {
|
| 788 |
+
cwd: repoRoot,
|
| 789 |
+
encoding: 'utf8',
|
| 790 |
+
env: buildScriptEnv(env)
|
| 791 |
+
});
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
function runScriptAsync(args = [], env = {}, options = {}) {
|
| 795 |
+
return new Promise((resolve) => {
|
| 796 |
+
const child = spawn(process.execPath, ['--import', 'tsx', scriptPath, ...args], {
|
| 797 |
+
cwd: repoRoot,
|
| 798 |
+
env: buildScriptEnv(env),
|
| 799 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 800 |
+
});
|
| 801 |
+
const startedAt = Date.now();
|
| 802 |
+
let stdout = '';
|
| 803 |
+
let stderr = '';
|
| 804 |
+
let killedByTest = false;
|
| 805 |
+
const killTimer = options.killAfterMs
|
| 806 |
+
? setTimeout(() => {
|
| 807 |
+
killedByTest = true;
|
| 808 |
+
child.kill('SIGTERM');
|
| 809 |
+
}, options.killAfterMs)
|
| 810 |
+
: undefined;
|
| 811 |
+
child.stdout.setEncoding('utf8');
|
| 812 |
+
child.stderr.setEncoding('utf8');
|
| 813 |
+
child.stdout.on('data', (chunk) => {
|
| 814 |
+
stdout += chunk;
|
| 815 |
+
});
|
| 816 |
+
child.stderr.on('data', (chunk) => {
|
| 817 |
+
stderr += chunk;
|
| 818 |
+
});
|
| 819 |
+
child.on('close', (status, signal) => {
|
| 820 |
+
if (killTimer) clearTimeout(killTimer);
|
| 821 |
+
resolve({ status, signal, stdout, stderr, killedByTest, elapsedMs: Date.now() - startedAt });
|
| 822 |
+
});
|
| 823 |
+
});
|
| 824 |
+
}
|
| 825 |
+
|
| 826 |
+
function listRealSmokeFiles() {
|
| 827 |
+
try {
|
| 828 |
+
return readdirSync(realSmokeOutputDir)
|
| 829 |
+
.filter((item) => item.endsWith('.png') || item.endsWith('.jpeg'))
|
| 830 |
+
.sort();
|
| 831 |
+
} catch {
|
| 832 |
+
return [];
|
| 833 |
+
}
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
function diffFiles(before, after) {
|
| 837 |
+
const beforeSet = new Set(before);
|
| 838 |
+
return after.filter((file) => !beforeSet.has(file));
|
| 839 |
+
}
|
| 840 |
+
|
| 841 |
+
function writeEnvFile(filepath, content) {
|
| 842 |
+
rmSync(filepath, { force: true });
|
| 843 |
+
mkdirSync(dirname(filepath), { recursive: true });
|
| 844 |
+
writeFileSync(filepath, `${content}\n`, 'utf8');
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
function buildScriptEnv(env = {}) {
|
| 848 |
+
const scriptEnv = { ...process.env };
|
| 849 |
+
for (const key of Object.keys(scriptEnv)) {
|
| 850 |
+
if (isSmokeEnvKey(key)) delete scriptEnv[key];
|
| 851 |
+
}
|
| 852 |
+
return {
|
| 853 |
+
...scriptEnv,
|
| 854 |
+
...env,
|
| 855 |
+
NODE_ENV: 'test',
|
| 856 |
+
IMAGE_REAL_SMOKE_SKIP_DOTENV: '1'
|
| 857 |
+
};
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
function isSmokeEnvKey(key) {
|
| 861 |
+
return (
|
| 862 |
+
key.startsWith('IMAGE_REAL_SMOKE_') ||
|
| 863 |
+
key.startsWith('OPENAI_CHANNEL_') ||
|
| 864 |
+
key === 'OPENAI_API_BASE_URL' ||
|
| 865 |
+
key === 'OPENAI_API_KEY' ||
|
| 866 |
+
key === 'OPENAI_RESPONSES_API_MODEL' ||
|
| 867 |
+
key === 'OPENAI_ROUTING_STRATEGY' ||
|
| 868 |
+
key === 'OPENAI_CHANNELS_JSON' ||
|
| 869 |
+
key === 'IMAGE_GENERATION_BACKEND' ||
|
| 870 |
+
key === 'IMAGE_STREAMING_STRATEGY' ||
|
| 871 |
+
key === 'ENABLE_RESPONSES_IMAGE_BACKEND' ||
|
| 872 |
+
key === 'IMAGE_OUTPUT_DIR' ||
|
| 873 |
+
key === 'APP_PASSWORD' ||
|
| 874 |
+
key === 'AGENT_API_TOKEN'
|
| 875 |
+
);
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
async function startLocalImagesSseUpstream() {
|
| 879 |
+
const server = createServer(async (request, response) => {
|
| 880 |
+
if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
|
| 881 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 882 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 883 |
+
return;
|
| 884 |
+
}
|
| 885 |
+
request.resume();
|
| 886 |
+
await new Promise((resolve) => request.on('end', resolve));
|
| 887 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 888 |
+
response.write(
|
| 889 |
+
`event: image_generation.completed\ndata: ${JSON.stringify({ type: 'image_generation.completed', b64_json: pngBase64 })}\n\n`
|
| 890 |
+
);
|
| 891 |
+
response.write('data: [DONE]\n\n');
|
| 892 |
+
response.end();
|
| 893 |
+
});
|
| 894 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 895 |
+
const address = server.address();
|
| 896 |
+
assert.ok(address && typeof address === 'object');
|
| 897 |
+
return {
|
| 898 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 899 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 900 |
+
};
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
async function startLocalFailingImagesSseUpstream() {
|
| 904 |
+
const server = createServer(async (request, response) => {
|
| 905 |
+
if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
|
| 906 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 907 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 908 |
+
return;
|
| 909 |
+
}
|
| 910 |
+
request.resume();
|
| 911 |
+
await new Promise((resolve) => request.on('end', resolve));
|
| 912 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 913 |
+
response.write(
|
| 914 |
+
`event: image_generation.completed\ndata: ${JSON.stringify({ type: 'image_generation.completed' })}\n\n`
|
| 915 |
+
);
|
| 916 |
+
response.write('data: [DONE]\n\n');
|
| 917 |
+
response.end();
|
| 918 |
+
});
|
| 919 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 920 |
+
const address = server.address();
|
| 921 |
+
assert.ok(address && typeof address === 'object');
|
| 922 |
+
return {
|
| 923 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 924 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 925 |
+
};
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
async function startLocalHangingImageUpstream() {
|
| 929 |
+
const sockets = new Set();
|
| 930 |
+
const server = createServer(async (request) => {
|
| 931 |
+
if (request.method === 'POST' && request.url?.endsWith('/images/generations')) {
|
| 932 |
+
request.resume();
|
| 933 |
+
return;
|
| 934 |
+
}
|
| 935 |
+
request.resume();
|
| 936 |
+
});
|
| 937 |
+
server.on('connection', (socket) => {
|
| 938 |
+
sockets.add(socket);
|
| 939 |
+
socket.on('close', () => sockets.delete(socket));
|
| 940 |
+
});
|
| 941 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 942 |
+
const address = server.address();
|
| 943 |
+
assert.ok(address && typeof address === 'object');
|
| 944 |
+
return {
|
| 945 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 946 |
+
close: () =>
|
| 947 |
+
new Promise((resolve, reject) => {
|
| 948 |
+
for (const socket of sockets) socket.destroy();
|
| 949 |
+
server.close((error) => (error ? reject(error) : resolve()));
|
| 950 |
+
})
|
| 951 |
+
};
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
async function startLocalImageAndResponsesUpstream() {
|
| 955 |
+
const calls = [];
|
| 956 |
+
const server = createServer(async (request, response) => {
|
| 957 |
+
if (request.method !== 'POST') {
|
| 958 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 959 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 960 |
+
return;
|
| 961 |
+
}
|
| 962 |
+
calls.push(request.url);
|
| 963 |
+
const body = await readRequestBody(request);
|
| 964 |
+
if (request.url?.endsWith('/images/generations')) {
|
| 965 |
+
respondToImageGenerationRequest(response, body);
|
| 966 |
+
return;
|
| 967 |
+
}
|
| 968 |
+
if (request.url?.endsWith('/responses')) {
|
| 969 |
+
respondToResponsesImageRequest(response, body);
|
| 970 |
+
return;
|
| 971 |
+
}
|
| 972 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 973 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 974 |
+
});
|
| 975 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 976 |
+
const address = server.address();
|
| 977 |
+
assert.ok(address && typeof address === 'object');
|
| 978 |
+
return {
|
| 979 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 980 |
+
calls,
|
| 981 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 982 |
+
};
|
| 983 |
+
}
|
| 984 |
+
|
| 985 |
+
async function readRequestBody(request) {
|
| 986 |
+
let body = '';
|
| 987 |
+
request.setEncoding('utf8');
|
| 988 |
+
request.on('data', (chunk) => {
|
| 989 |
+
body += chunk;
|
| 990 |
+
});
|
| 991 |
+
await new Promise((resolve) => request.on('end', resolve));
|
| 992 |
+
return body ? JSON.parse(body) : {};
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
function respondToImageGenerationRequest(response, body) {
|
| 996 |
+
if (body.stream === true) {
|
| 997 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 998 |
+
response.write(
|
| 999 |
+
`event: image_generation.completed\ndata: ${JSON.stringify({ type: 'image_generation.completed', b64_json: pngBase64 })}\n\n`
|
| 1000 |
+
);
|
| 1001 |
+
response.write('data: [DONE]\n\n');
|
| 1002 |
+
response.end();
|
| 1003 |
+
return;
|
| 1004 |
+
}
|
| 1005 |
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
| 1006 |
+
response.end(JSON.stringify({ data: [{ b64_json: pngBase64 }] }));
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
function respondToResponsesImageRequest(response, body) {
|
| 1010 |
+
if (body.tool_choice?.type !== 'image_generation' || body.tools?.[0]?.type !== 'image_generation') {
|
| 1011 |
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
| 1012 |
+
response.end(JSON.stringify({ error: { message: 'invalid responses image request' } }));
|
| 1013 |
+
return;
|
| 1014 |
+
}
|
| 1015 |
+
if (body.stream === true) {
|
| 1016 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 1017 |
+
response.write(
|
| 1018 |
+
`event: response.output_item.done\ndata: ${JSON.stringify({
|
| 1019 |
+
type: 'response.output_item.done',
|
| 1020 |
+
item: { type: 'image_generation_call', status: 'completed', result: pngBase64 }
|
| 1021 |
+
})}\n\n`
|
| 1022 |
+
);
|
| 1023 |
+
response.write('data: [DONE]\n\n');
|
| 1024 |
+
response.end();
|
| 1025 |
+
return;
|
| 1026 |
+
}
|
| 1027 |
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
| 1028 |
+
response.end(
|
| 1029 |
+
JSON.stringify({ output: [{ type: 'image_generation_call', status: 'completed', result: pngBase64 }] })
|
| 1030 |
+
);
|
| 1031 |
+
}
|
| 1032 |
+
|
| 1033 |
+
async function startLocalResponsesJsonUpstream() {
|
| 1034 |
+
const server = createServer(async (request, response) => {
|
| 1035 |
+
if (request.method !== 'POST' || !request.url?.endsWith('/responses')) {
|
| 1036 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 1037 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 1038 |
+
return;
|
| 1039 |
+
}
|
| 1040 |
+
let body = '';
|
| 1041 |
+
request.setEncoding('utf8');
|
| 1042 |
+
request.on('data', (chunk) => {
|
| 1043 |
+
body += chunk;
|
| 1044 |
+
});
|
| 1045 |
+
await new Promise((resolve) => request.on('end', resolve));
|
| 1046 |
+
const parsed = JSON.parse(body);
|
| 1047 |
+
if (
|
| 1048 |
+
parsed.stream !== false ||
|
| 1049 |
+
parsed.tool_choice?.type !== 'image_generation' ||
|
| 1050 |
+
parsed.tools?.[0]?.type !== 'image_generation'
|
| 1051 |
+
) {
|
| 1052 |
+
response.writeHead(400, { 'Content-Type': 'application/json' });
|
| 1053 |
+
response.end(JSON.stringify({ error: { message: 'invalid responses image request' } }));
|
| 1054 |
+
return;
|
| 1055 |
+
}
|
| 1056 |
+
response.writeHead(200, { 'Content-Type': 'application/json' });
|
| 1057 |
+
response.end(
|
| 1058 |
+
JSON.stringify({
|
| 1059 |
+
output: [{ type: 'image_generation_call', status: 'completed', result: pngBase64 }]
|
| 1060 |
+
})
|
| 1061 |
+
);
|
| 1062 |
+
});
|
| 1063 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 1064 |
+
const address = server.address();
|
| 1065 |
+
assert.ok(address && typeof address === 'object');
|
| 1066 |
+
return {
|
| 1067 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 1068 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 1069 |
+
};
|
| 1070 |
+
}
|
scripts/status.mjs
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { existsSync, readFileSync } from 'node:fs';
|
| 4 |
+
|
| 5 |
+
import { HF_SPACE_ID, HF_SPACE_URL } from './hf-space-doctor-utils.mjs';
|
| 6 |
+
import { isMainModule, parseJsonPayload, printJson, runCommand, runCommandStrict } from './command-center-utils.mjs';
|
| 7 |
+
|
| 8 |
+
const REMOTE_STATUS_TIMEOUT_MS = 30_000;
|
| 9 |
+
const IMAGE_UPSTREAM_REAL_SMOKE_CASES = [
|
| 10 |
+
{ id: 'original-images-json', prefix: 'IMAGE_REAL_SMOKE_ORIGINAL' },
|
| 11 |
+
{ id: 'gaoren-images-sse', prefix: 'IMAGE_REAL_SMOKE_GAOREN' },
|
| 12 |
+
{ id: 'sub2api-images-sse', prefix: 'IMAGE_REAL_SMOKE_SUB2API' },
|
| 13 |
+
{
|
| 14 |
+
id: 'sub2api-responses-json',
|
| 15 |
+
prefix: 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES',
|
| 16 |
+
fallbackPrefix: 'IMAGE_REAL_SMOKE_SUB2API',
|
| 17 |
+
requiresResponsesModel: true
|
| 18 |
+
},
|
| 19 |
+
{ id: 'gpt2image-responses-sse', prefix: 'IMAGE_REAL_SMOKE_GPT2IMAGE', requiresResponsesModel: true }
|
| 20 |
+
];
|
| 21 |
+
const IMAGE_UPSTREAM_FINAL_GATE_COMMAND =
|
| 22 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable';
|
| 23 |
+
const STATUS_ENV_FILES = [
|
| 24 |
+
{ path: '.env.local', override: false },
|
| 25 |
+
{ path: '.env.real-smoke.local', override: true }
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
export function buildAdminCommands() {
|
| 29 |
+
return {
|
| 30 |
+
doctor: 'npm run doctor',
|
| 31 |
+
status: 'npm run status',
|
| 32 |
+
verify: 'npm run verify',
|
| 33 |
+
deploy_local: 'npm run deploy:local',
|
| 34 |
+
deploy_space: 'npm run deploy:space',
|
| 35 |
+
agent_doctor: 'npm run agent:doctor',
|
| 36 |
+
hf_space_doctor: 'npm run doctor:hf-space',
|
| 37 |
+
hf_space_smoke: 'npm run smoke:hf-space'
|
| 38 |
+
};
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function parseGitStatusEntries(output) {
|
| 42 |
+
const entries = output.split('\0').filter(Boolean);
|
| 43 |
+
const paths = [];
|
| 44 |
+
for (let index = 0; index < entries.length; index += 1) {
|
| 45 |
+
const entry = entries[index];
|
| 46 |
+
const status = entry.slice(0, 2);
|
| 47 |
+
paths.push(entry.slice(3));
|
| 48 |
+
if (status.includes('R') || status.includes('C')) index += 1;
|
| 49 |
+
}
|
| 50 |
+
return paths;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
function readEnv(env, key) {
|
| 54 |
+
const value = env[key];
|
| 55 |
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function shouldSetStatusEnv(key, baseEnv, statusEnv, options) {
|
| 59 |
+
if (baseEnv[key] !== undefined) return false;
|
| 60 |
+
return options.override || statusEnv[key] === undefined;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function loadStatusEnvFile(statusEnv, baseEnv, options) {
|
| 64 |
+
if (!existsSync(options.path)) return;
|
| 65 |
+
for (const line of readFileSync(options.path, 'utf8').split(/\r?\n/)) {
|
| 66 |
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
|
| 67 |
+
if (!match || !shouldSetStatusEnv(match[1], baseEnv, statusEnv, options)) continue;
|
| 68 |
+
const value = match[2].trim();
|
| 69 |
+
const quoted =
|
| 70 |
+
value.length >= 2 &&
|
| 71 |
+
((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")));
|
| 72 |
+
statusEnv[match[1]] = quoted ? value.slice(1, -1) : value;
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export function readStatusEnvFromFiles(baseEnv = process.env, envFiles = STATUS_ENV_FILES) {
|
| 77 |
+
const statusEnv = { ...baseEnv };
|
| 78 |
+
for (const envFile of envFiles) {
|
| 79 |
+
loadStatusEnvFile(statusEnv, baseEnv, envFile);
|
| 80 |
+
}
|
| 81 |
+
return statusEnv;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function readSmokeEnvAlternatives(testCase, suffix) {
|
| 85 |
+
const keys = [`${testCase.prefix}_${suffix}`];
|
| 86 |
+
if (testCase.fallbackPrefix && testCase.fallbackPrefix !== testCase.prefix) {
|
| 87 |
+
keys.push(`${testCase.fallbackPrefix}_${suffix}`);
|
| 88 |
+
}
|
| 89 |
+
return keys;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
function readResponsesModelEnvAlternatives(testCase) {
|
| 93 |
+
return [`${testCase.prefix}_RESPONSES_MODEL`, 'OPENAI_RESPONSES_API_MODEL'];
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function readFirstStatusEnv(env, keys) {
|
| 97 |
+
for (const key of keys) {
|
| 98 |
+
const value = readEnv(env, key);
|
| 99 |
+
if (value) return { key, value };
|
| 100 |
+
}
|
| 101 |
+
return undefined;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function readBaseUrlValidationError(value) {
|
| 105 |
+
let url;
|
| 106 |
+
try {
|
| 107 |
+
url = new URL(value);
|
| 108 |
+
} catch {
|
| 109 |
+
return 'must_be_http_or_https_absolute_url';
|
| 110 |
+
}
|
| 111 |
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'must_use_http_or_https';
|
| 112 |
+
if (url.username || url.password) return 'must_not_include_credentials';
|
| 113 |
+
if (url.search || url.hash) return 'must_not_include_query_or_fragment';
|
| 114 |
+
return undefined;
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
function readTargetConfigured(testCase, env) {
|
| 118 |
+
const baseUrl = readFirstStatusEnv(env, readSmokeEnvAlternatives(testCase, 'BASE_URL'));
|
| 119 |
+
const apiKey = readFirstStatusEnv(env, readSmokeEnvAlternatives(testCase, 'API_KEY'));
|
| 120 |
+
const responsesModel = testCase.requiresResponsesModel
|
| 121 |
+
? readFirstStatusEnv(env, readResponsesModelEnvAlternatives(testCase))
|
| 122 |
+
: undefined;
|
| 123 |
+
const baseUrlError = baseUrl ? readBaseUrlValidationError(baseUrl.value) : undefined;
|
| 124 |
+
return {
|
| 125 |
+
baseUrl: Boolean(baseUrl),
|
| 126 |
+
apiKey: Boolean(apiKey),
|
| 127 |
+
responsesModel: Boolean(responsesModel),
|
| 128 |
+
invalidEnv: baseUrlError ? [{ key: baseUrl.key, reason: baseUrlError }] : []
|
| 129 |
+
};
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
function readMissingEnvAny(testCase, target) {
|
| 133 |
+
const groups = [];
|
| 134 |
+
if (!target.baseUrl) groups.push(readSmokeEnvAlternatives(testCase, 'BASE_URL'));
|
| 135 |
+
if (target.baseUrl && !target.apiKey) groups.push(readSmokeEnvAlternatives(testCase, 'API_KEY'));
|
| 136 |
+
if (target.baseUrl && testCase.requiresResponsesModel && !target.responsesModel) {
|
| 137 |
+
groups.push(readResponsesModelEnvAlternatives(testCase));
|
| 138 |
+
}
|
| 139 |
+
return groups;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
export function buildImageUpstreamRealSmokeStatus(env = process.env) {
|
| 143 |
+
const caseSummaries = IMAGE_UPSTREAM_REAL_SMOKE_CASES.map((testCase) => {
|
| 144 |
+
const target = readTargetConfigured(testCase, env);
|
| 145 |
+
const missingEnvAny = readMissingEnvAny(testCase, target);
|
| 146 |
+
return {
|
| 147 |
+
id: testCase.id,
|
| 148 |
+
configured: missingEnvAny.length === 0 && target.invalidEnv.length === 0,
|
| 149 |
+
...(missingEnvAny.length > 0 ? { missing_env_any: missingEnvAny } : {}),
|
| 150 |
+
...(target.invalidEnv.length > 0 ? { invalid_env: target.invalidEnv } : {})
|
| 151 |
+
};
|
| 152 |
+
});
|
| 153 |
+
const configuredCases = caseSummaries.filter((item) => item.configured).map((item) => item.id);
|
| 154 |
+
const missingCases = caseSummaries.filter(
|
| 155 |
+
(item) => Array.isArray(item.missing_env_any) && item.missing_env_any.length > 0
|
| 156 |
+
);
|
| 157 |
+
const invalidCases = caseSummaries.filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0);
|
| 158 |
+
return {
|
| 159 |
+
required_count: IMAGE_UPSTREAM_REAL_SMOKE_CASES.length,
|
| 160 |
+
required_cases: IMAGE_UPSTREAM_REAL_SMOKE_CASES.map((testCase) => testCase.id),
|
| 161 |
+
configuration_complete: missingCases.length === 0 && invalidCases.length === 0,
|
| 162 |
+
configured_count: configuredCases.length,
|
| 163 |
+
configured_cases: configuredCases,
|
| 164 |
+
missing_count: missingCases.length,
|
| 165 |
+
missing_cases: missingCases.map((item) => item.id),
|
| 166 |
+
missing_env_any: Object.fromEntries(missingCases.map((item) => [item.id, item.missing_env_any])),
|
| 167 |
+
invalid_count: invalidCases.length,
|
| 168 |
+
invalid_cases: invalidCases.map((item) => item.id),
|
| 169 |
+
invalid_env: Object.fromEntries(invalidCases.map((item) => [item.id, item.invalid_env])),
|
| 170 |
+
final_gate_command: IMAGE_UPSTREAM_FINAL_GATE_COMMAND
|
| 171 |
+
};
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function parseArgs(argv) {
|
| 175 |
+
const unknown = argv.find((arg) => !['--help', '-h', '--remote'].includes(arg));
|
| 176 |
+
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 177 |
+
return {
|
| 178 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 179 |
+
remote: argv.includes('--remote')
|
| 180 |
+
};
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
function printHelp() {
|
| 184 |
+
console.log(`Usage:
|
| 185 |
+
npm run status
|
| 186 |
+
npm run status -- --remote
|
| 187 |
+
|
| 188 |
+
Options:
|
| 189 |
+
--remote Include read-only Hugging Face Space runtime info.
|
| 190 |
+
--help Show this help.`);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function buildLocalStatus() {
|
| 194 |
+
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
|
| 195 |
+
const branch = runCommandStrict('git', ['branch', '--show-current']).trim();
|
| 196 |
+
const head = runCommandStrict('git', ['rev-parse', '--short', 'HEAD']).trim();
|
| 197 |
+
const changed = parseGitStatusEntries(runCommandStrict('git', ['status', '--porcelain=v1', '-z']));
|
| 198 |
+
return {
|
| 199 |
+
product: packageJson.name,
|
| 200 |
+
version: packageJson.version,
|
| 201 |
+
branch,
|
| 202 |
+
head,
|
| 203 |
+
dirty: changed.length > 0,
|
| 204 |
+
changed_files: changed,
|
| 205 |
+
node: process.version,
|
| 206 |
+
commands: buildAdminCommands(),
|
| 207 |
+
space: {
|
| 208 |
+
id: HF_SPACE_ID,
|
| 209 |
+
url: HF_SPACE_URL
|
| 210 |
+
},
|
| 211 |
+
agent: {
|
| 212 |
+
capabilities: '/api/agent/capabilities',
|
| 213 |
+
skill: 'skills/gpt-image-playground-agent/SKILL.md'
|
| 214 |
+
},
|
| 215 |
+
image_upstream_real_smoke: buildImageUpstreamRealSmokeStatus(readStatusEnvFromFiles(process.env))
|
| 216 |
+
};
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
export function readRemoteStatusFromResult(result) {
|
| 220 |
+
if (!result.ok) {
|
| 221 |
+
return { ok: false, error: result.error || result.stderr || result.stdout || 'Cannot read Hugging Face Space info.' };
|
| 222 |
+
}
|
| 223 |
+
let info;
|
| 224 |
+
try {
|
| 225 |
+
info = parseJsonPayload(result.stdout, 'hf spaces info');
|
| 226 |
+
} catch (error) {
|
| 227 |
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
| 228 |
+
}
|
| 229 |
+
return {
|
| 230 |
+
ok: true,
|
| 231 |
+
stage: info.runtime?.stage || 'unknown',
|
| 232 |
+
sha: info.sha || info.runtime?.raw?.sha || 'unknown',
|
| 233 |
+
hardware: info.runtime?.hardware || info.hardware || 'unknown'
|
| 234 |
+
};
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
function readRemoteStatus() {
|
| 238 |
+
return readRemoteStatusFromResult(
|
| 239 |
+
runCommand('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json'], { timeoutMs: REMOTE_STATUS_TIMEOUT_MS })
|
| 240 |
+
);
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
function main() {
|
| 244 |
+
const options = parseArgs(process.argv.slice(2));
|
| 245 |
+
if (options.help) {
|
| 246 |
+
printHelp();
|
| 247 |
+
return;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
const status = buildLocalStatus();
|
| 251 |
+
printJson({
|
| 252 |
+
ok: true,
|
| 253 |
+
...status,
|
| 254 |
+
...(options.remote ? { remote: readRemoteStatus() } : {})
|
| 255 |
+
});
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
try {
|
| 259 |
+
if (isMainModule(import.meta.url, process.argv[1])) main();
|
| 260 |
+
} catch (error) {
|
| 261 |
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
| 262 |
+
process.exit(1);
|
| 263 |
+
}
|
scripts/test-postgres-live.mjs
CHANGED
|
@@ -6,9 +6,17 @@ const testFiles = ['src/lib/agent-state-postgres.test.ts', 'src/app/api/agent/ag
|
|
| 6 |
const POSTGRES_READY_ATTEMPTS = 30;
|
| 7 |
const POSTGRES_READY_INTERVAL_MS = 1000;
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
function run(command, args, options = {}) {
|
| 10 |
const result = spawnSync(command, args, {
|
| 11 |
-
stdio: options.capture ? ['ignore', 'pipe', 'pipe'] :
|
| 12 |
encoding: 'utf8',
|
| 13 |
env: options.env || process.env
|
| 14 |
});
|
|
@@ -16,7 +24,7 @@ function run(command, args, options = {}) {
|
|
| 16 |
return result;
|
| 17 |
}
|
| 18 |
if (result.status !== 0) {
|
| 19 |
-
|
| 20 |
}
|
| 21 |
return result;
|
| 22 |
}
|
|
@@ -31,6 +39,10 @@ function runTests(databaseUrl) {
|
|
| 31 |
});
|
| 32 |
}
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
async function waitForPostgres(containerName) {
|
| 35 |
for (let attempt = 0; attempt < POSTGRES_READY_ATTEMPTS; attempt += 1) {
|
| 36 |
const result = spawnSync('docker', ['exec', containerName, 'pg_isready', '-U', 'agent_test', '-d', 'agent_test'], {
|
|
@@ -56,7 +68,11 @@ function readMappedPort(containerName) {
|
|
| 56 |
}
|
| 57 |
|
| 58 |
if (process.env.AGENT_POSTGRES_TEST_DATABASE_URL) {
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
process.exit(0);
|
| 61 |
}
|
| 62 |
|
|
@@ -81,6 +97,15 @@ try {
|
|
| 81 |
await waitForPostgres(containerName);
|
| 82 |
const port = readMappedPort(containerName);
|
| 83 |
runTests(`postgres://agent_test:agent_test@127.0.0.1:${port}/agent_test`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
} finally {
|
| 85 |
spawnSync('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
|
| 86 |
}
|
|
|
|
| 6 |
const POSTGRES_READY_ATTEMPTS = 30;
|
| 7 |
const POSTGRES_READY_INTERVAL_MS = 1000;
|
| 8 |
|
| 9 |
+
class CommandFailedError extends Error {
|
| 10 |
+
constructor(command, args, status, stderr = '') {
|
| 11 |
+
super(`${command} ${args.join(' ')} failed with exit code ${status}`);
|
| 12 |
+
this.status = status;
|
| 13 |
+
this.stderr = stderr;
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
function run(command, args, options = {}) {
|
| 18 |
const result = spawnSync(command, args, {
|
| 19 |
+
stdio: options.capture || options.silent ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
| 20 |
encoding: 'utf8',
|
| 21 |
env: options.env || process.env
|
| 22 |
});
|
|
|
|
| 24 |
return result;
|
| 25 |
}
|
| 26 |
if (result.status !== 0) {
|
| 27 |
+
throw new CommandFailedError(command, args, result.status || 1, result.stderr || '');
|
| 28 |
}
|
| 29 |
return result;
|
| 30 |
}
|
|
|
|
| 39 |
});
|
| 40 |
}
|
| 41 |
|
| 42 |
+
function statusFromError(error) {
|
| 43 |
+
return error instanceof CommandFailedError ? error.status : 1;
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
async function waitForPostgres(containerName) {
|
| 47 |
for (let attempt = 0; attempt < POSTGRES_READY_ATTEMPTS; attempt += 1) {
|
| 48 |
const result = spawnSync('docker', ['exec', containerName, 'pg_isready', '-U', 'agent_test', '-d', 'agent_test'], {
|
|
|
|
| 68 |
}
|
| 69 |
|
| 70 |
if (process.env.AGENT_POSTGRES_TEST_DATABASE_URL) {
|
| 71 |
+
try {
|
| 72 |
+
runTests(process.env.AGENT_POSTGRES_TEST_DATABASE_URL);
|
| 73 |
+
} catch (error) {
|
| 74 |
+
process.exit(statusFromError(error));
|
| 75 |
+
}
|
| 76 |
process.exit(0);
|
| 77 |
}
|
| 78 |
|
|
|
|
| 97 |
await waitForPostgres(containerName);
|
| 98 |
const port = readMappedPort(containerName);
|
| 99 |
runTests(`postgres://agent_test:agent_test@127.0.0.1:${port}/agent_test`);
|
| 100 |
+
} catch (error) {
|
| 101 |
+
process.exitCode = statusFromError(error);
|
| 102 |
+
if (error instanceof CommandFailedError) {
|
| 103 |
+
if (error.stderr.trim()) {
|
| 104 |
+
console.error(error.stderr.trim());
|
| 105 |
+
}
|
| 106 |
+
} else {
|
| 107 |
+
console.error(error instanceof Error ? error.message : String(error));
|
| 108 |
+
}
|
| 109 |
} finally {
|
| 110 |
spawnSync('docker', ['rm', '-f', containerName], { stdio: 'ignore' });
|
| 111 |
}
|
scripts/test-postgres-live.test.mjs
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
| 3 |
+
import os from 'node:os';
|
| 4 |
+
import path from 'node:path';
|
| 5 |
+
import { spawnSync } from 'node:child_process';
|
| 6 |
+
import { fileURLToPath } from 'node:url';
|
| 7 |
+
import { describe, it } from 'node:test';
|
| 8 |
+
|
| 9 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 10 |
+
const scriptPath = fileURLToPath(new URL('./test-postgres-live.mjs', import.meta.url));
|
| 11 |
+
|
| 12 |
+
describe('live PostgreSQL test launcher cleanup', () => {
|
| 13 |
+
it('removes the temporary PostgreSQL container when the test command fails', async () => {
|
| 14 |
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'pg-live-launcher-'));
|
| 15 |
+
const logPath = path.join(tempDir, 'docker.log');
|
| 16 |
+
const binDir = path.join(tempDir, 'bin');
|
| 17 |
+
await mkdir(binDir);
|
| 18 |
+
await writeFile(path.join(binDir, 'node'), buildFailingNodeShim(), { mode: 0o755 });
|
| 19 |
+
await writeFile(path.join(binDir, 'docker'), buildDockerShim(logPath), { mode: 0o755 });
|
| 20 |
+
|
| 21 |
+
try {
|
| 22 |
+
const result = spawnSync(process.execPath, [scriptPath], {
|
| 23 |
+
cwd: repoRoot,
|
| 24 |
+
encoding: 'utf8',
|
| 25 |
+
env: {
|
| 26 |
+
...process.env,
|
| 27 |
+
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`
|
| 28 |
+
}
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
assert.equal(result.status, 37, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 32 |
+
const dockerLog = await readFile(logPath, 'utf8');
|
| 33 |
+
assert.match(dockerLog, /^run /m);
|
| 34 |
+
assert.match(dockerLog, /^exec /m);
|
| 35 |
+
assert.match(dockerLog, /^port /m);
|
| 36 |
+
assert.match(dockerLog, /^rm -f gpt-image-agent-test-pg-/m);
|
| 37 |
+
} finally {
|
| 38 |
+
await rm(tempDir, { recursive: true, force: true });
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
it('preserves the test exit code when a PostgreSQL URL is provided', async () => {
|
| 43 |
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'pg-live-launcher-'));
|
| 44 |
+
const logPath = path.join(tempDir, 'docker.log');
|
| 45 |
+
const binDir = path.join(tempDir, 'bin');
|
| 46 |
+
await mkdir(binDir);
|
| 47 |
+
await writeFile(path.join(binDir, 'node'), buildFailingNodeShim(), { mode: 0o755 });
|
| 48 |
+
await writeFile(path.join(binDir, 'docker'), buildDockerShim(logPath), { mode: 0o755 });
|
| 49 |
+
|
| 50 |
+
try {
|
| 51 |
+
const result = spawnSync(process.execPath, [scriptPath], {
|
| 52 |
+
cwd: repoRoot,
|
| 53 |
+
encoding: 'utf8',
|
| 54 |
+
env: {
|
| 55 |
+
...process.env,
|
| 56 |
+
AGENT_POSTGRES_TEST_DATABASE_URL: 'postgres://agent_test:agent_test@127.0.0.1:55432/agent_test',
|
| 57 |
+
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`
|
| 58 |
+
}
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
assert.equal(result.status, 37, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 62 |
+
await assert.rejects(readFile(logPath, 'utf8'), { code: 'ENOENT' });
|
| 63 |
+
} finally {
|
| 64 |
+
await rm(tempDir, { recursive: true, force: true });
|
| 65 |
+
}
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
it('surfaces Docker startup stderr when the temporary container cannot start', async () => {
|
| 69 |
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), 'pg-live-launcher-'));
|
| 70 |
+
const logPath = path.join(tempDir, 'docker.log');
|
| 71 |
+
const binDir = path.join(tempDir, 'bin');
|
| 72 |
+
await mkdir(binDir);
|
| 73 |
+
await writeFile(path.join(binDir, 'node'), buildFailingNodeShim(), { mode: 0o755 });
|
| 74 |
+
await writeFile(path.join(binDir, 'docker'), buildFailingDockerRunShim(logPath), { mode: 0o755 });
|
| 75 |
+
|
| 76 |
+
try {
|
| 77 |
+
const result = spawnSync(process.execPath, [scriptPath], {
|
| 78 |
+
cwd: repoRoot,
|
| 79 |
+
encoding: 'utf8',
|
| 80 |
+
env: {
|
| 81 |
+
...process.env,
|
| 82 |
+
PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}`
|
| 83 |
+
}
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
assert.equal(result.status, 42, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
| 87 |
+
assert.match(result.stderr, /docker daemon unavailable/);
|
| 88 |
+
const dockerLog = await readFile(logPath, 'utf8');
|
| 89 |
+
assert.match(dockerLog, /^run /m);
|
| 90 |
+
assert.match(dockerLog, /^rm -f gpt-image-agent-test-pg-/m);
|
| 91 |
+
} finally {
|
| 92 |
+
await rm(tempDir, { recursive: true, force: true });
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
});
|
| 96 |
+
|
| 97 |
+
function buildFailingNodeShim() {
|
| 98 |
+
return `#!/bin/sh
|
| 99 |
+
exit 37
|
| 100 |
+
`;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function buildDockerShim(logPath) {
|
| 104 |
+
return `#!/bin/sh
|
| 105 |
+
printf '%s\\n' "$*" >> ${JSON.stringify(logPath)}
|
| 106 |
+
case "$1" in
|
| 107 |
+
run)
|
| 108 |
+
exit 0
|
| 109 |
+
;;
|
| 110 |
+
exec)
|
| 111 |
+
exit 0
|
| 112 |
+
;;
|
| 113 |
+
port)
|
| 114 |
+
printf '127.0.0.1:55432\\n'
|
| 115 |
+
exit 0
|
| 116 |
+
;;
|
| 117 |
+
rm)
|
| 118 |
+
exit 0
|
| 119 |
+
;;
|
| 120 |
+
*)
|
| 121 |
+
exit 2
|
| 122 |
+
;;
|
| 123 |
+
esac
|
| 124 |
+
`;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function buildFailingDockerRunShim(logPath) {
|
| 128 |
+
return `#!/bin/sh
|
| 129 |
+
printf '%s\\n' "$*" >> ${JSON.stringify(logPath)}
|
| 130 |
+
case "$1" in
|
| 131 |
+
run)
|
| 132 |
+
printf 'docker daemon unavailable\\n' >&2
|
| 133 |
+
exit 42
|
| 134 |
+
;;
|
| 135 |
+
rm)
|
| 136 |
+
exit 0
|
| 137 |
+
;;
|
| 138 |
+
*)
|
| 139 |
+
exit 2
|
| 140 |
+
;;
|
| 141 |
+
esac
|
| 142 |
+
`;
|
| 143 |
+
}
|
scripts/verify.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs';
|
| 4 |
+
|
| 5 |
+
const FULL_VERIFY_PLAN = [
|
| 6 |
+
{ name: 'test', command: 'npm', args: ['test'] },
|
| 7 |
+
{ name: 'lint', command: 'npm', args: ['run', 'lint'] },
|
| 8 |
+
{ name: 'lint:scripts', command: 'npm', args: ['run', 'lint:scripts'] },
|
| 9 |
+
{ name: 'build', command: 'npm', args: ['run', 'build'] },
|
| 10 |
+
{ name: 'diff-check', command: 'git', args: ['diff', '--check'] },
|
| 11 |
+
{ name: 'diff-cached-check', command: 'git', args: ['diff', '--cached', '--check'] }
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
const QUICK_VERIFY_PLAN = [
|
| 15 |
+
{ name: 'test:scripts', command: 'npm', args: ['run', 'test:scripts'] },
|
| 16 |
+
{ name: 'lint:scripts', command: 'npm', args: ['run', 'lint:scripts'] },
|
| 17 |
+
{ name: 'diff-check', command: 'git', args: ['diff', '--check'] },
|
| 18 |
+
{ name: 'diff-cached-check', command: 'git', args: ['diff', '--cached', '--check'] }
|
| 19 |
+
];
|
| 20 |
+
|
| 21 |
+
const POSTGRES_VERIFY_STEP = { name: 'test:postgres', command: 'npm', args: ['run', 'test:postgres'] };
|
| 22 |
+
|
| 23 |
+
export function buildVerifyPlan(options = {}) {
|
| 24 |
+
const source = options.quick ? QUICK_VERIFY_PLAN : FULL_VERIFY_PLAN;
|
| 25 |
+
const plan = source.filter((step) => !(options.skipBuild && step.name === 'build'));
|
| 26 |
+
if (!options.postgres) return plan;
|
| 27 |
+
const firstDiffCheckIndex = plan.findIndex((step) => step.name.startsWith('diff-'));
|
| 28 |
+
if (firstDiffCheckIndex === -1) return [...plan, POSTGRES_VERIFY_STEP];
|
| 29 |
+
return [...plan.slice(0, firstDiffCheckIndex), POSTGRES_VERIFY_STEP, ...plan.slice(firstDiffCheckIndex)];
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
function parseArgs(argv) {
|
| 33 |
+
const unknown = argv.find((arg) => !['--help', '-h', '--quick', '--skip-build', '--postgres'].includes(arg));
|
| 34 |
+
if (unknown) throw new Error(`Unknown option: ${unknown}`);
|
| 35 |
+
return {
|
| 36 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 37 |
+
postgres: argv.includes('--postgres'),
|
| 38 |
+
quick: argv.includes('--quick'),
|
| 39 |
+
skipBuild: argv.includes('--skip-build')
|
| 40 |
+
};
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function printHelp() {
|
| 44 |
+
console.log(`Usage:
|
| 45 |
+
npm run verify
|
| 46 |
+
npm run verify -- --quick
|
| 47 |
+
|
| 48 |
+
Options:
|
| 49 |
+
--quick Run script tests, script syntax checks, and git diff whitespace checks.
|
| 50 |
+
--postgres Include the live PostgreSQL gate with npm run test:postgres.
|
| 51 |
+
--skip-build Run the full plan without npm run build.
|
| 52 |
+
--help Show this help.`);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function runVerify(options) {
|
| 56 |
+
const checks = [];
|
| 57 |
+
for (const step of buildVerifyPlan(options)) {
|
| 58 |
+
const result = runCommand(step.command, step.args);
|
| 59 |
+
checks.push({
|
| 60 |
+
name: step.name,
|
| 61 |
+
command: [step.command, ...step.args].join(' '),
|
| 62 |
+
status: result.ok ? 'pass' : 'fail',
|
| 63 |
+
elapsed_ms: result.elapsed_ms,
|
| 64 |
+
...(result.ok ? {} : { output: pickFailureOutput(result) })
|
| 65 |
+
});
|
| 66 |
+
if (!result.ok) break;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
const ok = checks.every((check) => check.status === 'pass');
|
| 70 |
+
printJson({ ok, profile: options.quick ? 'quick' : 'full', postgres: Boolean(options.postgres), checks });
|
| 71 |
+
if (!ok) process.exit(1);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
| 76 |
+
const options = parseArgs(process.argv.slice(2));
|
| 77 |
+
if (options.help) {
|
| 78 |
+
printHelp();
|
| 79 |
+
} else {
|
| 80 |
+
runVerify(options);
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
} catch (error) {
|
| 84 |
+
printJson({ ok: false, error: error instanceof Error ? error.message : String(error) });
|
| 85 |
+
process.exit(1);
|
| 86 |
+
}
|
skills/gpt-image-playground-agent/SKILL.md
CHANGED
|
@@ -7,29 +7,40 @@ description: 当用户需要通过 API 调用已部署的 GPT Image Playground
|
|
| 7 |
|
| 8 |
通过用户已部署的 GPT Image Playground `/api/agent/*` 接口生成或编辑图片。不要假设服务一定在本机;不要模拟网页表单;直接使用 Agent API 契约、幂等键和产物 URL。
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
## 执行流程
|
| 11 |
|
| 12 |
1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
|
| 13 |
2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。
|
| 14 |
-
3. 读取 capabilities 中的认证方式、模型、模型级限制、Agent 流式边界、状态后端和端点路径;不要硬编码假设部署方式。
|
| 15 |
-
4. 为每个业务操作生成稳定的 `Idempotency-Key`。
|
| 16 |
-
5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点
|
| 17 |
6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。
|
| 18 |
7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
|
| 19 |
-
8. 不要把页面端 `POST /api/images` 当成 Agent
|
| 20 |
-
9. 读取 `agent_jobs`。
|
| 21 |
10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。
|
| 22 |
11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。
|
| 23 |
|
| 24 |
## 鉴权
|
| 25 |
|
| 26 |
-
如果服务端配置了 `AGENT_API_TOKEN`,发送:
|
| 27 |
|
| 28 |
```text
|
| 29 |
Authorization: Bearer <token>
|
| 30 |
```
|
| 31 |
|
| 32 |
-
此时
|
|
|
|
|
|
|
| 33 |
|
| 34 |
## 调用约束
|
| 35 |
|
|
@@ -39,13 +50,14 @@ Authorization: Bearer <token>
|
|
| 39 |
- 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
|
| 40 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 41 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 42 |
-
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate`
|
| 43 |
- 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。
|
| 44 |
-
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
|
|
|
|
| 45 |
|
| 46 |
## Job Polling
|
| 47 |
|
| 48 |
-
当 `agent_jobs.supported=true` 时,
|
| 49 |
|
| 50 |
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 51 |
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
|
@@ -53,7 +65,7 @@ Authorization: Bearer <token>
|
|
| 53 |
|
| 54 |
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 55 |
|
| 56 |
-
当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。
|
| 57 |
|
| 58 |
## 可用脚本
|
| 59 |
|
|
@@ -61,6 +73,17 @@ Authorization: Bearer <token>
|
|
| 61 |
- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
|
| 62 |
- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
生成脚本常用参数:
|
| 65 |
|
| 66 |
```bash
|
|
@@ -72,6 +95,19 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
|
| 72 |
"a product photo of a ceramic mug"
|
| 73 |
```
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
真实生图必须显式开启:
|
| 76 |
|
| 77 |
```bash
|
|
@@ -82,7 +118,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
|
| 82 |
"a product photo of a ceramic mug"
|
| 83 |
```
|
| 84 |
|
| 85 |
-
生成脚本会
|
| 86 |
|
| 87 |
编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。
|
| 88 |
|
|
@@ -101,14 +137,14 @@ OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream
|
|
| 101 |
|
| 102 |
- `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
|
| 103 |
- `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
|
| 104 |
-
- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时发送
|
| 105 |
- `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。
|
| 106 |
- `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
|
| 107 |
- `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
|
| 108 |
|
| 109 |
`GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。
|
| 110 |
|
| 111 |
-
脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。
|
| 112 |
|
| 113 |
## 参考
|
| 114 |
|
|
|
|
| 7 |
|
| 8 |
通过用户已部署的 GPT Image Playground `/api/agent/*` 接口生成或编辑图片。不要假设服务一定在本机;不要模拟网页表单;直接使用 Agent API 契约、幂等键和产物 URL。
|
| 9 |
|
| 10 |
+
## 路由硬规则
|
| 11 |
+
|
| 12 |
+
- 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。
|
| 13 |
+
- `edit` 且 `max(width,height)>2048` 时,必须使用页面端 `POST /api/images` form-data SSE 路径,不要走非流式 `/api/agent/images/edit`。
|
| 14 |
+
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE,并记录切换原因、失败清单和续跑锚点。
|
| 15 |
+
- 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。
|
| 16 |
+
- 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。
|
| 17 |
+
- 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。
|
| 18 |
+
|
| 19 |
## 执行流程
|
| 20 |
|
| 21 |
1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
|
| 22 |
2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。
|
| 23 |
+
3. 读取 capabilities 中的认证方式、模型、模型级限制、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端和端点路径;不要硬编码假设部署方式。
|
| 24 |
+
4. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key。
|
| 25 |
+
5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点对外始终返回最终 `AgentImageResponse` JSON;如 capabilities 声明 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用内部上游 SSE 消费。
|
| 26 |
6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。
|
| 27 |
7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
|
| 28 |
+
8. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复或明确诊断后切换。
|
| 29 |
+
9. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。
|
| 30 |
10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。
|
| 31 |
11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。
|
| 32 |
|
| 33 |
## 鉴权
|
| 34 |
|
| 35 |
+
Agent JSON、Agent edit、job 和 artifact 端点的鉴权以 `auth.schemes` 为准。如果服务端配置了 `AGENT_API_TOKEN`,发送:
|
| 36 |
|
| 37 |
```text
|
| 38 |
Authorization: Bearer <token>
|
| 39 |
```
|
| 40 |
|
| 41 |
+
此时 Agent 端点只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,Agent 端点发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一 Agent 鉴权方式。
|
| 42 |
+
|
| 43 |
+
页面端 `/api/images` SSE 是独立页面契约,读取 `agent_streaming.page_sse.auth`。当该字段声明 `required=true` 时,必须在 form-data 中发送 `passwordHash`;脚本侧对应环境变量是 `GPT_IMAGE_APP_PASSWORD_HASH`。即使 `auth.schemes` 只返回 `bearer`,混合配置下 page SSE 仍可能需要这个表单访问码哈希。页面 SSE 还会把同一业务 key 写入 form-data `clientRequestId`,长度不得超过 `agent_streaming.page_sse.client_request_id.max_length`。
|
| 44 |
|
| 45 |
## 调用约束
|
| 46 |
|
|
|
|
| 50 |
- 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
|
| 51 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 52 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 53 |
+
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。
|
| 54 |
- 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。
|
| 55 |
+
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
|
| 56 |
+
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
| 57 |
|
| 58 |
## Job Polling
|
| 59 |
|
| 60 |
+
当 `agent_jobs.supported=true` 时,显式 job 路径可使用:
|
| 61 |
|
| 62 |
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 63 |
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
|
|
|
| 65 |
|
| 66 |
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 67 |
|
| 68 |
+
当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。默认大图单次文生图已经切到页面端 `/api/images` SSE,job 不是默认路径。
|
| 69 |
|
| 70 |
## 可用脚本
|
| 71 |
|
|
|
|
| 73 |
- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
|
| 74 |
- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 75 |
|
| 76 |
+
生成和编辑脚本的 dry-run 输出会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。
|
| 77 |
+
|
| 78 |
+
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
| 79 |
+
|
| 80 |
+
- `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不��出 URL 或 API Key。
|
| 81 |
+
- `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。
|
| 82 |
+
- `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。
|
| 83 |
+
- `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。
|
| 84 |
+
- `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。
|
| 85 |
+
- `npm run agent:doctor`:执行只读 Agent API 契约检查,不触发真实生图。
|
| 86 |
+
|
| 87 |
生成脚本常用参数:
|
| 88 |
|
| 89 |
```bash
|
|
|
|
| 95 |
"a product photo of a ceramic mug"
|
| 96 |
```
|
| 97 |
|
| 98 |
+
启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON:
|
| 99 |
+
|
| 100 |
+
```bash
|
| 101 |
+
node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
| 102 |
+
--allow-billable \
|
| 103 |
+
--image-backend images-api \
|
| 104 |
+
--streaming-strategy newapi-keepalive-sse \
|
| 105 |
+
--partial-images 2 \
|
| 106 |
+
--size 4096x4096 \
|
| 107 |
+
--quality high \
|
| 108 |
+
"a product photo of a ceramic mug"
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
真实生图必须显式开启:
|
| 112 |
|
| 113 |
```bash
|
|
|
|
| 118 |
"a product photo of a ceramic mug"
|
| 119 |
```
|
| 120 |
|
| 121 |
+
生成脚本会对 `max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE;如果 capabilities 未声明 `agent_streaming.page_sse.supported=true`,脚本会显式失败,不会静默降级到 Agent JSON。如果页面流式失败,脚本会返回结构化失败结果,先诊断再决定是否用 `--agent` 或 `--job` 重新执行,不会自动发起第二次请求。也可以用 `--page-sse` 强制页面流式,或用 `--agent` 强制非流式 Agent generate,`--job` 仍可显式选择 job 路径。上游流式字段支持 `--image-backend`、`--streaming-strategy`、`--partial-images`;默认不发送这些字段,保持服务端默认非流式基线。
|
| 122 |
|
| 123 |
编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。
|
| 124 |
|
|
|
|
| 137 |
|
| 138 |
- `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
|
| 139 |
- `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
|
| 140 |
+
- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时,Agent 端点发送为 `X-App-Password-Hash`,页面 SSE 发送为 form-data `passwordHash`。
|
| 141 |
- `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。
|
| 142 |
- `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
|
| 143 |
- `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
|
| 144 |
|
| 145 |
`GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。
|
| 146 |
|
| 147 |
+
脚本会把服务返回的相对产物路径补充为绝对 URL,页面 SSE 的相对 `path` 会补充 `absolute_path`,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。
|
| 148 |
|
| 149 |
## 参考
|
| 150 |
|
skills/gpt-image-playground-agent/references/api.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
| 1 |
# GPT Image Playground Agent API 参考
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
## 辅助脚本
|
| 4 |
|
| 5 |
- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
|
|
@@ -9,9 +19,10 @@
|
|
| 9 |
生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。
|
| 10 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 11 |
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
|
| 12 |
-
鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。
|
| 13 |
-
当服务返回相对 `content_url` 或 `
|
| 14 |
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
|
|
|
| 15 |
|
| 16 |
生成脚本参数:
|
| 17 |
|
|
@@ -61,24 +72,44 @@ GET /api/agent/capabilities
|
|
| 61 |
|
| 62 |
关键字段:
|
| 63 |
|
| 64 |
-
- `auth.required`:是否需要鉴权。
|
| 65 |
-
- `auth.schemes`:当前
|
| 66 |
- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
|
| 67 |
- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
|
| 68 |
- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
|
| 69 |
- `model_limits.gpt-image-2.max_aspect`:最大长短边比例,当前为 `3`。
|
| 70 |
- `model_limits.gpt-image-2.min_pixels`:最小总像素,当前为 `655360`。
|
| 71 |
- `model_limits.gpt-image-2.recommended_presets`:推荐尺寸预设。
|
| 72 |
-
- `model_limits.gpt-image-2.
|
| 73 |
- `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
|
| 74 |
- `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
- `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
- `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。
|
| 77 |
- `agent_jobs.mode`:当前为 `job_polling`。
|
| 78 |
- `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
|
| 79 |
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 80 |
|
| 81 |
-
当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,
|
| 82 |
|
| 83 |
## Job Polling
|
| 84 |
|
|
@@ -147,11 +178,14 @@ Content-Type: application/json
|
|
| 147 |
"output_format": "png",
|
| 148 |
"background": "auto",
|
| 149 |
"moderation": "auto",
|
| 150 |
-
"response_mode": "path"
|
|
|
|
|
|
|
|
|
|
| 151 |
}
|
| 152 |
```
|
| 153 |
|
| 154 |
-
Agent 生成端点
|
| 155 |
|
| 156 |
响应:
|
| 157 |
|
|
@@ -198,6 +232,8 @@ Content-Type: multipart/form-data
|
|
| 198 |
- `image_0..image_9`:源图片。
|
| 199 |
- `mask`:可选 PNG 遮罩。
|
| 200 |
|
|
|
|
|
|
|
| 201 |
## 产物元数据
|
| 202 |
|
| 203 |
```http
|
|
@@ -228,6 +264,8 @@ DELETE /api/agent/artifacts/{id}
|
|
| 228 |
"selected_channel_id": "default",
|
| 229 |
"upstream_host": "api.example.test",
|
| 230 |
"upstream_status": 524,
|
|
|
|
|
|
|
| 231 |
"transport_error": false,
|
| 232 |
"retry_after_seconds": 15,
|
| 233 |
"channel_cooldown_scope": "channel",
|
|
|
|
| 1 |
# GPT Image Playground Agent API 参考
|
| 2 |
|
| 3 |
+
## 目录
|
| 4 |
+
|
| 5 |
+
- [辅助脚本](#辅助脚本)
|
| 6 |
+
- [能力查询](#能力查询)
|
| 7 |
+
- [Job Polling](#job-polling)
|
| 8 |
+
- [生成图片](#生成图片)
|
| 9 |
+
- [编辑图片](#编辑图片)
|
| 10 |
+
- [产物元数据](#产物元数据)
|
| 11 |
+
- [错误](#错误)
|
| 12 |
+
|
| 13 |
## 辅助脚本
|
| 14 |
|
| 15 |
- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
|
|
|
|
| 19 |
生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。
|
| 20 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 21 |
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
|
| 22 |
+
Agent 端点鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,Agent 端点才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。页面端 `/api/images` SSE 另看 `agent_streaming.page_sse.auth`;当其声明 `required=true` 时,form-data 必须包含 `passwordHash`。
|
| 23 |
+
当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。
|
| 24 |
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 25 |
+
页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
|
| 26 |
|
| 27 |
生成脚本参数:
|
| 28 |
|
|
|
|
| 72 |
|
| 73 |
关键字段:
|
| 74 |
|
| 75 |
+
- `auth.required`:Agent 端点是否需要鉴权。
|
| 76 |
+
- `auth.schemes`:Agent 端点当前实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。
|
| 77 |
- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
|
| 78 |
- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
|
| 79 |
- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
|
| 80 |
- `model_limits.gpt-image-2.max_aspect`:最大长短边比例,当前为 `3`。
|
| 81 |
- `model_limits.gpt-image-2.min_pixels`:最小总像素,当前为 `655360`。
|
| 82 |
- `model_limits.gpt-image-2.recommended_presets`:推荐尺寸预设。
|
| 83 |
+
- `model_limits.gpt-image-2.large_image_risk`:大尺寸请求的长耗时风险说明,当前适用于 `max_edge>2048`。
|
| 84 |
- `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
|
| 85 |
- `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
|
| 86 |
+
- `agent_streaming.upstream_sse`:Agent generate 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。
|
| 87 |
+
- `agent_streaming.upstream_sse.image_backends`:支持 `images-api`、`responses-image-generation`。
|
| 88 |
+
- `agent_streaming.upstream_sse.enabled_image_backends`:当前运行时可直接使用的 Agent 上游 SSE 后端;`responses-image-generation` 只有在所需环境变量齐备时才出现。
|
| 89 |
+
- `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
|
| 90 |
+
- `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前为 `openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。
|
| 91 |
- `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
|
| 92 |
+
- `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
|
| 93 |
+
- `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
|
| 94 |
+
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时必须使用页面端 `/api/images` SSE。
|
| 95 |
+
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
| 96 |
+
- `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
|
| 97 |
+
- `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。
|
| 98 |
+
- `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径。
|
| 99 |
+
- `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。
|
| 100 |
+
- `defaults.image_backend`:Agent generate 默认 `images-api`。
|
| 101 |
+
- `defaults.streaming_strategy`:Agent generate 默认 `off`,不会默认向上游发送 `stream=true`。
|
| 102 |
+
- `defaults.partial_images`:Agent generate 默认 `2`,仅在显式启用上游 SSE 时使用。
|
| 103 |
+
- `supported.image_backends`:机器可读的图片后端枚举。
|
| 104 |
+
- `supported.enabled_image_backends`:当前运行时可直接使用的图片后端。
|
| 105 |
+
- `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。
|
| 106 |
+
- `supported.streaming_strategies`:机器可读的流式兼容策略枚举。
|
| 107 |
- `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。
|
| 108 |
- `agent_jobs.mode`:当前为 `job_polling`。
|
| 109 |
- `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
|
| 110 |
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 111 |
|
| 112 |
+
当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,job 路径仍然可用,但普通大图单次文生图的默认路径已经切到页面端 `/api/images` SSE。高分辨率 edit 和复杂 UI 批量生产不应走 Agent 非流式 edit,优先按 `routing_rules` 使用页面端 `/api/images` SSE。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。
|
| 113 |
|
| 114 |
## Job Polling
|
| 115 |
|
|
|
|
| 178 |
"output_format": "png",
|
| 179 |
"background": "auto",
|
| 180 |
"moderation": "auto",
|
| 181 |
+
"response_mode": "path",
|
| 182 |
+
"image_backend": "images-api",
|
| 183 |
+
"streaming_strategy": "off",
|
| 184 |
+
"partial_images": 2
|
| 185 |
}
|
| 186 |
```
|
| 187 |
|
| 188 |
+
Agent 生成端点对外始终返回最终 JSON,不会对客户端返回 SSE。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。若 capabilities 中 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用服务端内部上游 SSE 消费,最终响应仍是 `AgentImageResponse`。
|
| 189 |
|
| 190 |
响应:
|
| 191 |
|
|
|
|
| 232 |
- `image_0..image_9`:源图片。
|
| 233 |
- `mask`:可选 PNG 遮罩。
|
| 234 |
|
| 235 |
+
当 `size` 的最大边大于 `2048` 时,Agent edit 端点会返回 `validation_error`,不会联系上游;该场景必须按 `routing_rules.high_resolution_edit` 使用页面端 `/api/images` form-data SSE 路径。
|
| 236 |
+
|
| 237 |
## 产物元数据
|
| 238 |
|
| 239 |
```http
|
|
|
|
| 264 |
"selected_channel_id": "default",
|
| 265 |
"upstream_host": "api.example.test",
|
| 266 |
"upstream_status": 524,
|
| 267 |
+
"upstream_event_type": "image_generation.partial_image",
|
| 268 |
+
"partial_image_count": 1,
|
| 269 |
"transport_error": false,
|
| 270 |
"retry_after_seconds": 15,
|
| 271 |
"channel_cooldown_scope": "channel",
|
skills/gpt-image-playground-agent/scripts/edit-image.mjs
CHANGED
|
@@ -55,6 +55,7 @@ try {
|
|
| 55 |
}
|
| 56 |
|
| 57 |
if (options.dryRun || (!contractCheck && !options.allowBillable)) {
|
|
|
|
| 58 |
console.log(
|
| 59 |
JSON.stringify(
|
| 60 |
{
|
|
@@ -62,6 +63,7 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
|
|
| 62 |
billable: false,
|
| 63 |
dry_run: true,
|
| 64 |
endpoint: `${baseUrl}/api/agent/images/edit`,
|
|
|
|
| 65 |
idempotency_key: idempotencyKey,
|
| 66 |
request: {
|
| 67 |
image_path: imagePath,
|
|
@@ -80,6 +82,23 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) {
|
|
| 80 |
process.exit(0);
|
| 81 |
}
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
function parseArgs(argv) {
|
| 84 |
const parsed = {
|
| 85 |
model: 'gpt-image-2',
|
|
@@ -124,6 +143,30 @@ function absoluteUrl(value) {
|
|
| 124 |
return new URL(value, `${baseUrl}/`).toString();
|
| 125 |
}
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
function enrichImageUrls(result) {
|
| 128 |
if (!result || !Array.isArray(result.images)) return result;
|
| 129 |
return {
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
if (options.dryRun || (!contractCheck && !options.allowBillable)) {
|
| 58 |
+
const routingGuidance = buildEditRoutingGuidance(options);
|
| 59 |
console.log(
|
| 60 |
JSON.stringify(
|
| 61 |
{
|
|
|
|
| 63 |
billable: false,
|
| 64 |
dry_run: true,
|
| 65 |
endpoint: `${baseUrl}/api/agent/images/edit`,
|
| 66 |
+
routing_guidance: routingGuidance,
|
| 67 |
idempotency_key: idempotencyKey,
|
| 68 |
request: {
|
| 69 |
image_path: imagePath,
|
|
|
|
| 82 |
process.exit(0);
|
| 83 |
}
|
| 84 |
|
| 85 |
+
const routingGuidance = buildEditRoutingGuidance(options);
|
| 86 |
+
if (routingGuidance.strength === 'must_use') {
|
| 87 |
+
console.error(
|
| 88 |
+
JSON.stringify(
|
| 89 |
+
{
|
| 90 |
+
ok: false,
|
| 91 |
+
billable: false,
|
| 92 |
+
error: '当前请求命中高分辨率 edit 路由硬规则;请使用页面端 /api/images form-data SSE 路径。',
|
| 93 |
+
routing_guidance: routingGuidance
|
| 94 |
+
},
|
| 95 |
+
null,
|
| 96 |
+
2
|
| 97 |
+
)
|
| 98 |
+
);
|
| 99 |
+
process.exit(2);
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
function parseArgs(argv) {
|
| 103 |
const parsed = {
|
| 104 |
model: 'gpt-image-2',
|
|
|
|
| 143 |
return new URL(value, `${baseUrl}/`).toString();
|
| 144 |
}
|
| 145 |
|
| 146 |
+
function buildEditRoutingGuidance(parsed) {
|
| 147 |
+
if (readMaxImageEdge(parsed.size) > 2048) {
|
| 148 |
+
return {
|
| 149 |
+
recommended_endpoint: '/api/images',
|
| 150 |
+
transport: 'page_sse',
|
| 151 |
+
strength: 'must_use',
|
| 152 |
+
reason: 'Agent edit is non-streaming; high-resolution edit should use the page form-data SSE endpoint.'
|
| 153 |
+
};
|
| 154 |
+
}
|
| 155 |
+
return {
|
| 156 |
+
recommended_endpoint: '/api/agent/images/edit',
|
| 157 |
+
transport: 'agent_json',
|
| 158 |
+
strength: 'default',
|
| 159 |
+
reason: 'Normal edit requests can use the Agent JSON response contract.'
|
| 160 |
+
};
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function readMaxImageEdge(size) {
|
| 164 |
+
if (typeof size !== 'string') return 0;
|
| 165 |
+
const match = size.match(/^(\d+)x(\d+)$/);
|
| 166 |
+
if (!match) return 0;
|
| 167 |
+
return Math.max(Number(match[1]), Number(match[2]));
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
function enrichImageUrls(result) {
|
| 171 |
if (!result || !Array.isArray(result.images)) return result;
|
| 172 |
return {
|
skills/gpt-image-playground-agent/scripts/generate-image.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
import crypto from 'node:crypto';
|
| 3 |
import fs from 'node:fs';
|
|
|
|
| 4 |
import {
|
| 5 |
errorMessage,
|
| 6 |
normalizeBaseUrl,
|
|
@@ -12,6 +13,20 @@ import {
|
|
| 12 |
sleep
|
| 13 |
} from './lib/script-utils.mjs';
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
|
| 16 |
const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
|
| 17 |
const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
|
|
@@ -73,8 +88,9 @@ if (isNonBillableDryRun(options, contractCheck)) {
|
|
| 73 |
ok: true,
|
| 74 |
billable: false,
|
| 75 |
dry_run: true,
|
| 76 |
-
endpoint: dryRunEndpoint(options.
|
| 77 |
-
|
|
|
|
| 78 |
idempotency_key: idempotencyKey,
|
| 79 |
request: requestBody,
|
| 80 |
next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。'
|
|
@@ -89,6 +105,10 @@ if (isNonBillableDryRun(options, contractCheck)) {
|
|
| 89 |
try {
|
| 90 |
var capabilities = await readCapabilities();
|
| 91 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
console.error(errorMessage(error));
|
| 93 |
process.exit(1);
|
| 94 |
}
|
|
@@ -99,12 +119,31 @@ if (contractCheck) {
|
|
| 99 |
}
|
| 100 |
|
| 101 |
try {
|
| 102 |
-
if (shouldUseJobPolling(capabilities,
|
| 103 |
await runGenerateJob();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
} else {
|
| 105 |
-
await runGenerateRequest();
|
| 106 |
}
|
| 107 |
} catch (error) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
console.error(errorMessage(error));
|
| 109 |
process.exit(1);
|
| 110 |
}
|
|
@@ -117,10 +156,13 @@ function parseArgs(argv) {
|
|
| 117 |
n: '1',
|
| 118 |
format: 'png',
|
| 119 |
responseMode: 'path',
|
|
|
|
|
|
|
|
|
|
| 120 |
timeoutMs: undefined,
|
| 121 |
promptFile: undefined,
|
| 122 |
idempotencyKey: undefined,
|
| 123 |
-
|
| 124 |
dryRun: false,
|
| 125 |
allowBillable: false,
|
| 126 |
help: false,
|
|
@@ -130,8 +172,9 @@ function parseArgs(argv) {
|
|
| 130 |
const arg = argv[index];
|
| 131 |
if (arg === '--dry-run') parsed.dryRun = true;
|
| 132 |
else if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 133 |
-
else if (arg === '--job') parsed.
|
| 134 |
-
else if (arg === '--no-job') parsed.
|
|
|
|
| 135 |
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 136 |
else if (arg === '--contract-check') continue;
|
| 137 |
else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
|
|
@@ -140,6 +183,9 @@ function parseArgs(argv) {
|
|
| 140 |
else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg);
|
| 141 |
else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
|
| 142 |
else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
|
|
|
|
|
|
|
|
|
|
| 143 |
else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
|
| 144 |
else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg);
|
| 145 |
else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
|
|
@@ -160,32 +206,71 @@ function readPrompt(parsed, { readPromptFile }) {
|
|
| 160 |
}
|
| 161 |
|
| 162 |
function buildRequestBody(promptValue, parsed) {
|
| 163 |
-
return
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
|
| 174 |
function buildDryRunRequestBody(parsed) {
|
| 175 |
-
const body =
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
| 183 |
if (parsed.promptFile) {
|
| 184 |
return { ...body, prompt_file: parsed.promptFile };
|
| 185 |
}
|
| 186 |
return { ...body, prompt: parsed.promptParts.join(' ') };
|
| 187 |
}
|
| 188 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
function hasPromptSource(parsed) {
|
| 190 |
return Boolean(parsed.promptFile || parsed.promptParts.length > 0);
|
| 191 |
}
|
|
@@ -217,14 +302,47 @@ function enrichImageUrls(result) {
|
|
| 217 |
};
|
| 218 |
}
|
| 219 |
|
| 220 |
-
function dryRunEndpoint(
|
| 221 |
-
if (
|
| 222 |
-
if (
|
| 223 |
-
return `${baseUrl}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
}
|
| 225 |
|
| 226 |
async function readCapabilities() {
|
| 227 |
-
const { response, result, text } = await fetchJson(`${baseUrl}
|
| 228 |
headers: authHeaders(),
|
| 229 |
timeoutMs
|
| 230 |
});
|
|
@@ -234,12 +352,12 @@ async function readCapabilities() {
|
|
| 234 |
return result;
|
| 235 |
}
|
| 236 |
|
| 237 |
-
async function runGenerateRequest() {
|
| 238 |
let lastResult;
|
| 239 |
let lastRetryAfter = null;
|
| 240 |
|
| 241 |
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 242 |
-
const { response, result } = await fetchJson(`${baseUrl}
|
| 243 |
method: 'POST',
|
| 244 |
headers: {
|
| 245 |
'Content-Type': 'application/json',
|
|
@@ -251,7 +369,7 @@ async function runGenerateRequest() {
|
|
| 251 |
});
|
| 252 |
|
| 253 |
if (response.ok) {
|
| 254 |
-
console.log(JSON.stringify(enrichImageUrls(result), null, 2));
|
| 255 |
process.exit(0);
|
| 256 |
}
|
| 257 |
|
|
@@ -266,12 +384,340 @@ async function runGenerateRequest() {
|
|
| 266 |
process.exit(1);
|
| 267 |
}
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
async function runGenerateJob() {
|
| 270 |
let lastResult;
|
| 271 |
let lastRetryAfter = null;
|
| 272 |
|
| 273 |
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 274 |
-
const { response, result } = await fetchJson(`${baseUrl}
|
| 275 |
method: 'POST',
|
| 276 |
headers: {
|
| 277 |
'Content-Type': 'application/json',
|
|
@@ -303,7 +749,7 @@ async function pollJobResult(job) {
|
|
| 303 |
if (!job || typeof job.id !== 'string') {
|
| 304 |
throw new Error('创建 job 的响应缺少 job.id。');
|
| 305 |
}
|
| 306 |
-
const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url ||
|
| 307 |
const deadlineMs = Date.now() + timeoutMs;
|
| 308 |
let lastResult;
|
| 309 |
let lastRetryAfter = job.retry_after_seconds || 1;
|
|
@@ -328,7 +774,7 @@ async function pollJobResult(job) {
|
|
| 328 |
|
| 329 |
async function runContractCheck(capabilitiesValue) {
|
| 330 |
const checks = [];
|
| 331 |
-
const { response, result } = await fetchJson(`${baseUrl}
|
| 332 |
method: 'POST',
|
| 333 |
headers: {
|
| 334 |
'Content-Type': 'application/json',
|
|
@@ -338,14 +784,14 @@ async function runContractCheck(capabilitiesValue) {
|
|
| 338 |
timeoutMs
|
| 339 |
});
|
| 340 |
if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
|
| 341 |
-
checks.push({ endpoint:
|
| 342 |
} else {
|
| 343 |
console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
|
| 344 |
process.exit(1);
|
| 345 |
}
|
| 346 |
|
| 347 |
if (supportsJobPolling(capabilitiesValue)) {
|
| 348 |
-
const jobCheck = await fetchJson(`${baseUrl}
|
| 349 |
method: 'POST',
|
| 350 |
headers: {
|
| 351 |
'Content-Type': 'application/json',
|
|
@@ -361,7 +807,7 @@ async function runContractCheck(capabilitiesValue) {
|
|
| 361 |
process.exit(1);
|
| 362 |
}
|
| 363 |
checks.push({
|
| 364 |
-
endpoint:
|
| 365 |
status: jobCheck.response.status,
|
| 366 |
error_code: jobCheck.result.error.code
|
| 367 |
});
|
|
@@ -371,15 +817,27 @@ async function runContractCheck(capabilitiesValue) {
|
|
| 371 |
}
|
| 372 |
|
| 373 |
async function fetchJson(url, init) {
|
| 374 |
-
const controller = new AbortController();
|
| 375 |
-
const timeout = setTimeout(() => controller.abort(), init.timeoutMs);
|
| 376 |
try {
|
| 377 |
-
const
|
| 378 |
-
delete fetchInit.timeoutMs;
|
| 379 |
-
const response = await fetch(url, { ...fetchInit, signal: controller.signal });
|
| 380 |
const text = await response.text();
|
| 381 |
const result = parseJsonResponse(text, response.ok, url);
|
| 382 |
return { response, result, text };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 383 |
} catch (error) {
|
| 384 |
const message = errorMessage(error);
|
| 385 |
throw new Error(`请求失败:${url}。${message}`);
|
|
@@ -388,6 +846,18 @@ async function fetchJson(url, init) {
|
|
| 388 |
}
|
| 389 |
}
|
| 390 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
function parseJsonResponse(text, isOk, url) {
|
| 392 |
if (!text) return null;
|
| 393 |
try {
|
|
@@ -407,16 +877,39 @@ function supportsJobPolling(capabilitiesValue) {
|
|
| 407 |
return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling');
|
| 408 |
}
|
| 409 |
|
| 410 |
-
function
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
if (!supportsJobPolling(capabilitiesValue)) {
|
| 413 |
-
|
| 414 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
return false;
|
| 417 |
}
|
| 418 |
-
if (
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
}
|
| 421 |
|
| 422 |
function readMaxImageEdge(size) {
|
|
@@ -426,9 +919,38 @@ function readMaxImageEdge(size) {
|
|
| 426 |
return Math.max(Number(match[1]), Number(match[2]));
|
| 427 |
}
|
| 428 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
function printUsage() {
|
| 430 |
console.error('用法:generate-image.mjs [options] <prompt>');
|
| 431 |
console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
|
| 432 |
-
console.error(
|
|
|
|
|
|
|
| 433 |
console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check');
|
| 434 |
}
|
|
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
import crypto from 'node:crypto';
|
| 3 |
import fs from 'node:fs';
|
| 4 |
+
import { AGENT_ENDPOINTS, buildAgentJobResultPath } from '../../../src/lib/agent-api-paths.mjs';
|
| 5 |
import {
|
| 6 |
errorMessage,
|
| 7 |
normalizeBaseUrl,
|
|
|
|
| 13 |
sleep
|
| 14 |
} from './lib/script-utils.mjs';
|
| 15 |
|
| 16 |
+
const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']);
|
| 17 |
+
const RESPONSE_MODES = new Set(['path', 'base64', 'both']);
|
| 18 |
+
const STREAMING_STRATEGIES = new Set([
|
| 19 |
+
'off',
|
| 20 |
+
'auto',
|
| 21 |
+
'openai-sse',
|
| 22 |
+
'newapi-keepalive-sse',
|
| 23 |
+
'responses-sse',
|
| 24 |
+
'force-sse'
|
| 25 |
+
]);
|
| 26 |
+
const MIN_PARTIAL_IMAGES = 1;
|
| 27 |
+
const MAX_PARTIAL_IMAGES = 3;
|
| 28 |
+
const MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH = 128;
|
| 29 |
+
const PAGE_SSE_ENDPOINT = '/api/images';
|
| 30 |
const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
|
| 31 |
const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
|
| 32 |
const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
|
|
|
|
| 88 |
ok: true,
|
| 89 |
billable: false,
|
| 90 |
dry_run: true,
|
| 91 |
+
endpoint: dryRunEndpoint(requestBody, options.routeMode),
|
| 92 |
+
route_mode: options.routeMode,
|
| 93 |
+
routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode),
|
| 94 |
idempotency_key: idempotencyKey,
|
| 95 |
request: requestBody,
|
| 96 |
next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。'
|
|
|
|
| 105 |
try {
|
| 106 |
var capabilities = await readCapabilities();
|
| 107 |
} catch (error) {
|
| 108 |
+
if (isScriptError(error)) {
|
| 109 |
+
console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
|
| 110 |
+
process.exit(1);
|
| 111 |
+
}
|
| 112 |
console.error(errorMessage(error));
|
| 113 |
process.exit(1);
|
| 114 |
}
|
|
|
|
| 119 |
}
|
| 120 |
|
| 121 |
try {
|
| 122 |
+
if (shouldUseJobPolling(capabilities, options.routeMode)) {
|
| 123 |
await runGenerateJob();
|
| 124 |
+
} else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) {
|
| 125 |
+
try {
|
| 126 |
+
const result = await runPageSseRequest();
|
| 127 |
+
console.log(
|
| 128 |
+
JSON.stringify(
|
| 129 |
+
buildSuccessOutput(formatPageSseOutput(result), { transport: 'page_sse', endpoint: PAGE_SSE_ENDPOINT }),
|
| 130 |
+
null,
|
| 131 |
+
2
|
| 132 |
+
)
|
| 133 |
+
);
|
| 134 |
+
process.exit(0);
|
| 135 |
+
} catch (error) {
|
| 136 |
+
console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
|
| 137 |
+
process.exit(1);
|
| 138 |
+
}
|
| 139 |
} else {
|
| 140 |
+
await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } });
|
| 141 |
}
|
| 142 |
} catch (error) {
|
| 143 |
+
if (isScriptError(error)) {
|
| 144 |
+
console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2));
|
| 145 |
+
process.exit(1);
|
| 146 |
+
}
|
| 147 |
console.error(errorMessage(error));
|
| 148 |
process.exit(1);
|
| 149 |
}
|
|
|
|
| 156 |
n: '1',
|
| 157 |
format: 'png',
|
| 158 |
responseMode: 'path',
|
| 159 |
+
imageBackend: undefined,
|
| 160 |
+
streamingStrategy: undefined,
|
| 161 |
+
partialImages: undefined,
|
| 162 |
timeoutMs: undefined,
|
| 163 |
promptFile: undefined,
|
| 164 |
idempotencyKey: undefined,
|
| 165 |
+
routeMode: 'auto',
|
| 166 |
dryRun: false,
|
| 167 |
allowBillable: false,
|
| 168 |
help: false,
|
|
|
|
| 172 |
const arg = argv[index];
|
| 173 |
if (arg === '--dry-run') parsed.dryRun = true;
|
| 174 |
else if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 175 |
+
else if (arg === '--job') parsed.routeMode = 'job';
|
| 176 |
+
else if (arg === '--no-job' || arg === '--agent') parsed.routeMode = 'agent';
|
| 177 |
+
else if (arg === '--page-sse') parsed.routeMode = 'page_sse';
|
| 178 |
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 179 |
else if (arg === '--contract-check') continue;
|
| 180 |
else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
|
|
|
|
| 183 |
else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg);
|
| 184 |
else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
|
| 185 |
else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
|
| 186 |
+
else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg);
|
| 187 |
+
else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg);
|
| 188 |
+
else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg);
|
| 189 |
else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
|
| 190 |
else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg);
|
| 191 |
else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
|
|
|
|
| 206 |
}
|
| 207 |
|
| 208 |
function buildRequestBody(promptValue, parsed) {
|
| 209 |
+
return addUpstreamStrategyFields(
|
| 210 |
+
{
|
| 211 |
+
prompt: promptValue || 'contract check',
|
| 212 |
+
model: parsed.model,
|
| 213 |
+
n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
|
| 214 |
+
size: parsed.size,
|
| 215 |
+
quality: parsed.quality,
|
| 216 |
+
output_format: normalizeOutputFormat(parsed.format),
|
| 217 |
+
response_mode: parsed.responseMode
|
| 218 |
+
},
|
| 219 |
+
parsed
|
| 220 |
+
);
|
| 221 |
}
|
| 222 |
|
| 223 |
function buildDryRunRequestBody(parsed) {
|
| 224 |
+
const body = addUpstreamStrategyFields(
|
| 225 |
+
{
|
| 226 |
+
model: parsed.model,
|
| 227 |
+
n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
|
| 228 |
+
size: parsed.size,
|
| 229 |
+
quality: parsed.quality,
|
| 230 |
+
output_format: normalizeOutputFormat(parsed.format),
|
| 231 |
+
response_mode: parsed.responseMode
|
| 232 |
+
},
|
| 233 |
+
parsed
|
| 234 |
+
);
|
| 235 |
if (parsed.promptFile) {
|
| 236 |
return { ...body, prompt_file: parsed.promptFile };
|
| 237 |
}
|
| 238 |
return { ...body, prompt: parsed.promptParts.join(' ') };
|
| 239 |
}
|
| 240 |
|
| 241 |
+
function addUpstreamStrategyFields(body, parsed) {
|
| 242 |
+
validateUpstreamStrategyOptions(parsed);
|
| 243 |
+
return {
|
| 244 |
+
...body,
|
| 245 |
+
...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}),
|
| 246 |
+
...(parsed.streamingStrategy ? { streaming_strategy: parsed.streamingStrategy } : {}),
|
| 247 |
+
...(parsed.partialImages ? { partial_images: readPartialImages(parsed.partialImages) } : {})
|
| 248 |
+
};
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
function validateUpstreamStrategyOptions(parsed) {
|
| 252 |
+
if (!RESPONSE_MODES.has(parsed.responseMode)) {
|
| 253 |
+
throw new Error('--response-mode 必须是 path、base64 或 both。');
|
| 254 |
+
}
|
| 255 |
+
if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) {
|
| 256 |
+
throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。');
|
| 257 |
+
}
|
| 258 |
+
if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) {
|
| 259 |
+
throw new Error('--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。');
|
| 260 |
+
}
|
| 261 |
+
if (parsed.routeMode === 'page_sse' && parsed.streamingStrategy === 'off') {
|
| 262 |
+
throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。');
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function readPartialImages(value) {
|
| 267 |
+
const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2);
|
| 268 |
+
if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) {
|
| 269 |
+
throw new Error('--partial-images 必须是 1 到 3 的整数。');
|
| 270 |
+
}
|
| 271 |
+
return parsed;
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
function hasPromptSource(parsed) {
|
| 275 |
return Boolean(parsed.promptFile || parsed.promptParts.length > 0);
|
| 276 |
}
|
|
|
|
| 302 |
};
|
| 303 |
}
|
| 304 |
|
| 305 |
+
function dryRunEndpoint(body, routeMode) {
|
| 306 |
+
if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`;
|
| 307 |
+
if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`;
|
| 308 |
+
if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`;
|
| 309 |
+
return isLargeGenerate(body) && isPageSseAllowed(body)
|
| 310 |
+
? `${baseUrl}${PAGE_SSE_ENDPOINT}`
|
| 311 |
+
: `${baseUrl}${AGENT_ENDPOINTS.generate}`;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
function buildGenerateRoutingGuidance(body, routeMode) {
|
| 315 |
+
if (routeMode === 'job') {
|
| 316 |
+
return {
|
| 317 |
+
recommended_endpoint: AGENT_ENDPOINTS.create_generate_job,
|
| 318 |
+
transport: 'agent_job_polling',
|
| 319 |
+
strength: 'recommended',
|
| 320 |
+
reason: 'Explicit --job requests use Agent job polling.'
|
| 321 |
+
};
|
| 322 |
+
}
|
| 323 |
+
if (routeMode === 'page_sse' && !isPageSseAllowed(body)) {
|
| 324 |
+
throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。');
|
| 325 |
+
}
|
| 326 |
+
if ((routeMode === 'page_sse' || (routeMode !== 'agent' && isLargeGenerate(body))) && isPageSseAllowed(body)) {
|
| 327 |
+
return {
|
| 328 |
+
recommended_endpoint: PAGE_SSE_ENDPOINT,
|
| 329 |
+
transport: 'page_sse',
|
| 330 |
+
strength: 'recommended',
|
| 331 |
+
fallback_endpoint: AGENT_ENDPOINTS.generate,
|
| 332 |
+
fallback_mode: 'manual_after_diagnosis',
|
| 333 |
+
reason: 'Generate requests with max_edge>2048 should use page form-data SSE first; if the stream fails, diagnose first and rerun manually with Agent JSON.'
|
| 334 |
+
};
|
| 335 |
+
}
|
| 336 |
+
return {
|
| 337 |
+
recommended_endpoint: AGENT_ENDPOINTS.generate,
|
| 338 |
+
transport: 'agent_json',
|
| 339 |
+
strength: 'default',
|
| 340 |
+
reason: 'Normal single-image generate requests use the Agent JSON response contract.'
|
| 341 |
+
};
|
| 342 |
}
|
| 343 |
|
| 344 |
async function readCapabilities() {
|
| 345 |
+
const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, {
|
| 346 |
headers: authHeaders(),
|
| 347 |
timeoutMs
|
| 348 |
});
|
|
|
|
| 352 |
return result;
|
| 353 |
}
|
| 354 |
|
| 355 |
+
async function runGenerateRequest(options = {}) {
|
| 356 |
let lastResult;
|
| 357 |
let lastRetryAfter = null;
|
| 358 |
|
| 359 |
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 360 |
+
const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
|
| 361 |
method: 'POST',
|
| 362 |
headers: {
|
| 363 |
'Content-Type': 'application/json',
|
|
|
|
| 369 |
});
|
| 370 |
|
| 371 |
if (response.ok) {
|
| 372 |
+
console.log(JSON.stringify(buildSuccessOutput(enrichImageUrls(result), options.routing), null, 2));
|
| 373 |
process.exit(0);
|
| 374 |
}
|
| 375 |
|
|
|
|
| 384 |
process.exit(1);
|
| 385 |
}
|
| 386 |
|
| 387 |
+
async function runPageSseRequest() {
|
| 388 |
+
const url = `${baseUrl}${PAGE_SSE_ENDPOINT}`;
|
| 389 |
+
const formData = buildPageSseFormData();
|
| 390 |
+
const controller = new AbortController();
|
| 391 |
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
| 392 |
+
try {
|
| 393 |
+
let response;
|
| 394 |
+
try {
|
| 395 |
+
response = await fetch(url, {
|
| 396 |
+
method: 'POST',
|
| 397 |
+
body: formData,
|
| 398 |
+
signal: controller.signal
|
| 399 |
+
});
|
| 400 |
+
} catch (error) {
|
| 401 |
+
throw new Error(`请求失败:${url}。${errorMessage(error)}`);
|
| 402 |
+
}
|
| 403 |
+
try {
|
| 404 |
+
const contentType = response.headers.get('content-type') || '';
|
| 405 |
+
if (contentType.includes('text/event-stream')) {
|
| 406 |
+
return await collectPageSseResult(response, controller.signal);
|
| 407 |
+
}
|
| 408 |
+
const text = await response.text();
|
| 409 |
+
if (!response.ok) {
|
| 410 |
+
throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text);
|
| 411 |
+
}
|
| 412 |
+
return parseJsonResponse(text, true, url);
|
| 413 |
+
} catch (error) {
|
| 414 |
+
if (controller.signal.aborted) {
|
| 415 |
+
throw new Error(`请求失败:${url}。${errorMessage(error)}`);
|
| 416 |
+
}
|
| 417 |
+
throw error;
|
| 418 |
+
}
|
| 419 |
+
} finally {
|
| 420 |
+
clearTimeout(timeout);
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
function assertPageSseReady(capabilitiesValue) {
|
| 425 |
+
const pageSse = capabilitiesValue?.agent_streaming?.page_sse;
|
| 426 |
+
if (!supportsPageSse(capabilitiesValue)) {
|
| 427 |
+
throw createScriptError(
|
| 428 |
+
'page_sse_unavailable',
|
| 429 |
+
'大图默认路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级到 Agent JSON。'
|
| 430 |
+
);
|
| 431 |
+
}
|
| 432 |
+
if (pageSse?.auth?.required === true && !passwordHash) {
|
| 433 |
+
throw createScriptError(
|
| 434 |
+
'page_sse_auth_required',
|
| 435 |
+
'页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。'
|
| 436 |
+
);
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
function buildPageSseFormData() {
|
| 441 |
+
const formData = new FormData();
|
| 442 |
+
assertPageSseClientRequestIdLength(idempotencyKey);
|
| 443 |
+
formData.append('mode', 'generate');
|
| 444 |
+
formData.append('prompt', requestBody.prompt);
|
| 445 |
+
formData.append('model', requestBody.model);
|
| 446 |
+
formData.append('n', String(requestBody.n));
|
| 447 |
+
formData.append('size', requestBody.size);
|
| 448 |
+
formData.append('quality', requestBody.quality);
|
| 449 |
+
formData.append('output_format', requestBody.output_format);
|
| 450 |
+
formData.append('response_mode', requestBody.response_mode);
|
| 451 |
+
formData.append('clientRequestId', idempotencyKey);
|
| 452 |
+
formData.append('stream', 'true');
|
| 453 |
+
formData.append('partial_images', String(requestBody.partial_images || 2));
|
| 454 |
+
if (requestBody.image_backend) formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend));
|
| 455 |
+
if (requestBody.streaming_strategy) {
|
| 456 |
+
formData.append('image_streaming_strategy', requestBody.streaming_strategy);
|
| 457 |
+
}
|
| 458 |
+
if (requestBody.background) formData.append('background', requestBody.background);
|
| 459 |
+
if (requestBody.moderation) formData.append('moderation', requestBody.moderation);
|
| 460 |
+
if (requestBody.output_compression !== undefined) {
|
| 461 |
+
formData.append('output_compression', String(requestBody.output_compression));
|
| 462 |
+
}
|
| 463 |
+
if (passwordHash) formData.append('passwordHash', passwordHash);
|
| 464 |
+
return formData;
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function assertPageSseClientRequestIdLength(clientRequestId) {
|
| 468 |
+
if (clientRequestId.length > MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH) {
|
| 469 |
+
throw createScriptError(
|
| 470 |
+
'page_sse_client_request_id_too_long',
|
| 471 |
+
`页面 SSE 的 clientRequestId 不能超过 ${MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH} 个字符;请缩短 Idempotency-Key。`
|
| 472 |
+
);
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
function formatPageSseOutput(result) {
|
| 477 |
+
if (!result || !Array.isArray(result.images)) return result;
|
| 478 |
+
return {
|
| 479 |
+
...result,
|
| 480 |
+
images: result.images.map((image) => formatPageSseImage(image))
|
| 481 |
+
};
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
function formatPageSseImage(image) {
|
| 485 |
+
const output = { ...image };
|
| 486 |
+
if (output.path) {
|
| 487 |
+
output.absolute_path = absoluteUrl(output.path);
|
| 488 |
+
if (requestBody.response_mode === 'path') {
|
| 489 |
+
delete output.b64_json;
|
| 490 |
+
}
|
| 491 |
+
}
|
| 492 |
+
return output;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
function normalizeImageBackendForPage(value) {
|
| 496 |
+
if (value === 'images') return 'images-api';
|
| 497 |
+
if (value === 'responses') return 'responses-image-generation';
|
| 498 |
+
return value;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
function readPageSseClientRequestId(event) {
|
| 502 |
+
if (typeof event.clientRequestId === 'string') return event.clientRequestId;
|
| 503 |
+
if (typeof event.client_request_id === 'string') return event.client_request_id;
|
| 504 |
+
return undefined;
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
function normalizePageSseImage(image, fallbackClientRequestId) {
|
| 508 |
+
const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId;
|
| 509 |
+
return {
|
| 510 |
+
...image,
|
| 511 |
+
output_format: image.outputFormat || image.output_format || requestBody.output_format,
|
| 512 |
+
...(clientRequestId ? { clientRequestId } : {})
|
| 513 |
+
};
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) {
|
| 517 |
+
if (!Array.isArray(doneImages) || doneImages.length === 0) {
|
| 518 |
+
return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId));
|
| 519 |
+
}
|
| 520 |
+
const imageCount = Math.max(doneImages.length, completedImages.length);
|
| 521 |
+
return Array.from({ length: imageCount }, (_, index) =>
|
| 522 |
+
normalizePageSseImage({ ...(completedImages[index] || {}), ...(doneImages[index] || {}) }, fallbackClientRequestId)
|
| 523 |
+
);
|
| 524 |
+
}
|
| 525 |
+
|
| 526 |
+
async function collectPageSseResult(response, signal) {
|
| 527 |
+
const reader = response.body?.getReader();
|
| 528 |
+
if (!reader) throw new Error('页面 SSE 响应缺少 body。');
|
| 529 |
+
const decoder = new TextDecoder();
|
| 530 |
+
const state = { completedImages: [], usage: undefined, actualCost: undefined, doneReceived: false };
|
| 531 |
+
let buffer = '';
|
| 532 |
+
while (true) {
|
| 533 |
+
const { done, value } = await readPageSseChunk(reader, signal);
|
| 534 |
+
if (done) break;
|
| 535 |
+
buffer += decoder.decode(value, { stream: true });
|
| 536 |
+
const events = buffer.split(/\r?\n\r?\n/);
|
| 537 |
+
buffer = events.pop() || '';
|
| 538 |
+
for (const rawEvent of events) {
|
| 539 |
+
applyPageSseEvent(state, rawEvent);
|
| 540 |
+
}
|
| 541 |
+
}
|
| 542 |
+
buffer += decoder.decode();
|
| 543 |
+
if (buffer.trim()) applyPageSseEvent(state, buffer);
|
| 544 |
+
if (state.completedImages.length === 0) {
|
| 545 |
+
throw new Error('页面 SSE 未返回最终图片。');
|
| 546 |
+
}
|
| 547 |
+
if (!state.doneReceived) {
|
| 548 |
+
throw new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。');
|
| 549 |
+
}
|
| 550 |
+
return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost };
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
function readPageSseChunk(reader, signal) {
|
| 554 |
+
if (!signal) return reader.read();
|
| 555 |
+
if (signal.aborted) {
|
| 556 |
+
return Promise.reject(new Error('请求超时。'));
|
| 557 |
+
}
|
| 558 |
+
return new Promise((resolve, reject) => {
|
| 559 |
+
const onAbort = () => reject(new Error('请求超时。'));
|
| 560 |
+
signal.addEventListener('abort', onAbort, { once: true });
|
| 561 |
+
reader.read().then(resolve, reject).finally(() => {
|
| 562 |
+
signal.removeEventListener('abort', onAbort);
|
| 563 |
+
});
|
| 564 |
+
});
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function applyPageSseEvent(state, rawEvent) {
|
| 568 |
+
const event = parsePageSseEvent(rawEvent);
|
| 569 |
+
if (!event) return;
|
| 570 |
+
if (event.type === 'error') {
|
| 571 |
+
throw createPageSseStreamError(event);
|
| 572 |
+
}
|
| 573 |
+
if (event.type === 'completed' && event.filename) {
|
| 574 |
+
state.completedImages.push(
|
| 575 |
+
normalizePageSseImage(
|
| 576 |
+
{
|
| 577 |
+
filename: event.filename,
|
| 578 |
+
b64_json: event.b64_json,
|
| 579 |
+
path: event.path,
|
| 580 |
+
output_format: event.outputFormat || event.output_format || requestBody.output_format
|
| 581 |
+
},
|
| 582 |
+
readPageSseClientRequestId(event)
|
| 583 |
+
)
|
| 584 |
+
);
|
| 585 |
+
return;
|
| 586 |
+
}
|
| 587 |
+
if (event.type === 'done') {
|
| 588 |
+
state.doneReceived = true;
|
| 589 |
+
const clientRequestId = readPageSseClientRequestId(event);
|
| 590 |
+
state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId);
|
| 591 |
+
state.usage = event.usage;
|
| 592 |
+
state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost;
|
| 593 |
+
}
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
function formatPageSseError(value) {
|
| 597 |
+
if (typeof value === 'string' && value.trim()) return value;
|
| 598 |
+
if (value && typeof value === 'object') {
|
| 599 |
+
if (typeof value.message === 'string' && value.message.trim()) return value.message;
|
| 600 |
+
if (typeof value.code === 'string' && value.code.trim()) return value.code;
|
| 601 |
+
try {
|
| 602 |
+
return JSON.stringify(value);
|
| 603 |
+
} catch {
|
| 604 |
+
return '页面 SSE 返回错误事件。';
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
return '页面 SSE 返回错误事件。';
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
function createPageSseStreamError(event) {
|
| 611 |
+
const error = new Error(formatPageSseError(event.error));
|
| 612 |
+
const status = readPageSseStreamStatus(event);
|
| 613 |
+
if (Number.isInteger(status)) {
|
| 614 |
+
error.streamStatus = status;
|
| 615 |
+
}
|
| 616 |
+
return error;
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
function readPageSseStreamStatus(event) {
|
| 620 |
+
if (Number.isInteger(event.status)) return event.status;
|
| 621 |
+
if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) {
|
| 622 |
+
return event.error.status;
|
| 623 |
+
}
|
| 624 |
+
return undefined;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
function parsePageSseEvent(rawEvent) {
|
| 628 |
+
const lines = rawEvent.split(/\r?\n/);
|
| 629 |
+
const data = lines
|
| 630 |
+
.filter((line) => line.startsWith('data: '))
|
| 631 |
+
.map((line) => line.slice(6))
|
| 632 |
+
.join('\n')
|
| 633 |
+
.trim();
|
| 634 |
+
if (!data || data === '[DONE]') return undefined;
|
| 635 |
+
try {
|
| 636 |
+
return JSON.parse(data);
|
| 637 |
+
} catch (error) {
|
| 638 |
+
throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`);
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
function buildSuccessOutput(result, routing) {
|
| 643 |
+
return routing ? { ...result, routing } : result;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
function buildPageSseFailureOutput(error) {
|
| 647 |
+
if (isScriptError(error)) {
|
| 648 |
+
return buildPageSseScriptFailure(error);
|
| 649 |
+
}
|
| 650 |
+
if (isPageSseRequestRejected(error)) {
|
| 651 |
+
return buildPageSseRequestRejectedFailure(error);
|
| 652 |
+
}
|
| 653 |
+
return buildBillablePageSseFailure(error);
|
| 654 |
+
}
|
| 655 |
+
|
| 656 |
+
function buildPageSseRouting(fallbackMode) {
|
| 657 |
+
return {
|
| 658 |
+
transport: 'page_sse',
|
| 659 |
+
endpoint: PAGE_SSE_ENDPOINT,
|
| 660 |
+
fallback_endpoint: AGENT_ENDPOINTS.generate,
|
| 661 |
+
fallback_mode: fallbackMode
|
| 662 |
+
};
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
function buildPageSseScriptFailure(error) {
|
| 666 |
+
return {
|
| 667 |
+
ok: false,
|
| 668 |
+
billable: false,
|
| 669 |
+
error: {
|
| 670 |
+
code: error.scriptCode,
|
| 671 |
+
message: errorMessage(error)
|
| 672 |
+
},
|
| 673 |
+
routing: buildPageSseRouting('manual_after_diagnosis'),
|
| 674 |
+
next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。'
|
| 675 |
+
};
|
| 676 |
+
}
|
| 677 |
+
|
| 678 |
+
function buildPageSseRequestRejectedFailure(error) {
|
| 679 |
+
return {
|
| 680 |
+
ok: false,
|
| 681 |
+
billable: false,
|
| 682 |
+
error: {
|
| 683 |
+
code: 'page_sse_request_rejected',
|
| 684 |
+
status: error.status,
|
| 685 |
+
message: errorMessage(error)
|
| 686 |
+
},
|
| 687 |
+
routing: buildPageSseRouting('fix_request_before_retry'),
|
| 688 |
+
next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。'
|
| 689 |
+
};
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
function buildBillablePageSseFailure(error) {
|
| 693 |
+
return {
|
| 694 |
+
ok: false,
|
| 695 |
+
billable: true,
|
| 696 |
+
error: {
|
| 697 |
+
code: 'page_sse_failed',
|
| 698 |
+
...buildPageSseFailureStatus(error),
|
| 699 |
+
message: errorMessage(error)
|
| 700 |
+
},
|
| 701 |
+
routing: buildPageSseRouting('manual_after_diagnosis'),
|
| 702 |
+
next_step:
|
| 703 |
+
'先诊断页面流式失败原因,再决定是否用 --agent 重新执行同一业务请求;不要自动重试同一个请求。'
|
| 704 |
+
};
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
function buildPageSseFailureStatus(error) {
|
| 708 |
+
if (error && typeof error === 'object') {
|
| 709 |
+
if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus };
|
| 710 |
+
if (Number.isInteger(error.status)) return { status: error.status };
|
| 711 |
+
}
|
| 712 |
+
return {};
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
async function runGenerateJob() {
|
| 716 |
let lastResult;
|
| 717 |
let lastRetryAfter = null;
|
| 718 |
|
| 719 |
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 720 |
+
const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
|
| 721 |
method: 'POST',
|
| 722 |
headers: {
|
| 723 |
'Content-Type': 'application/json',
|
|
|
|
| 749 |
if (!job || typeof job.id !== 'string') {
|
| 750 |
throw new Error('创建 job 的响应缺少 job.id。');
|
| 751 |
}
|
| 752 |
+
const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || buildAgentJobResultPath(job.id), 'job.result_url');
|
| 753 |
const deadlineMs = Date.now() + timeoutMs;
|
| 754 |
let lastResult;
|
| 755 |
let lastRetryAfter = job.retry_after_seconds || 1;
|
|
|
|
| 774 |
|
| 775 |
async function runContractCheck(capabilitiesValue) {
|
| 776 |
const checks = [];
|
| 777 |
+
const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, {
|
| 778 |
method: 'POST',
|
| 779 |
headers: {
|
| 780 |
'Content-Type': 'application/json',
|
|
|
|
| 784 |
timeoutMs
|
| 785 |
});
|
| 786 |
if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
|
| 787 |
+
checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code });
|
| 788 |
} else {
|
| 789 |
console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
|
| 790 |
process.exit(1);
|
| 791 |
}
|
| 792 |
|
| 793 |
if (supportsJobPolling(capabilitiesValue)) {
|
| 794 |
+
const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, {
|
| 795 |
method: 'POST',
|
| 796 |
headers: {
|
| 797 |
'Content-Type': 'application/json',
|
|
|
|
| 807 |
process.exit(1);
|
| 808 |
}
|
| 809 |
checks.push({
|
| 810 |
+
endpoint: AGENT_ENDPOINTS.create_generate_job,
|
| 811 |
status: jobCheck.response.status,
|
| 812 |
error_code: jobCheck.result.error.code
|
| 813 |
});
|
|
|
|
| 817 |
}
|
| 818 |
|
| 819 |
async function fetchJson(url, init) {
|
|
|
|
|
|
|
| 820 |
try {
|
| 821 |
+
const response = await fetchWithTimeout(url, init);
|
|
|
|
|
|
|
| 822 |
const text = await response.text();
|
| 823 |
const result = parseJsonResponse(text, response.ok, url);
|
| 824 |
return { response, result, text };
|
| 825 |
+
} catch (error) {
|
| 826 |
+
const message = errorMessage(error);
|
| 827 |
+
if (message.startsWith(`请求失败:${url}。`)) {
|
| 828 |
+
throw error;
|
| 829 |
+
}
|
| 830 |
+
throw new Error(`请求失败:${url}。${message}`);
|
| 831 |
+
}
|
| 832 |
+
}
|
| 833 |
+
|
| 834 |
+
async function fetchWithTimeout(url, init) {
|
| 835 |
+
const controller = new AbortController();
|
| 836 |
+
const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs);
|
| 837 |
+
try {
|
| 838 |
+
const fetchInit = { ...init };
|
| 839 |
+
delete fetchInit.timeoutMs;
|
| 840 |
+
return await fetch(url, { ...fetchInit, signal: controller.signal });
|
| 841 |
} catch (error) {
|
| 842 |
const message = errorMessage(error);
|
| 843 |
throw new Error(`请求失败:${url}。${message}`);
|
|
|
|
| 846 |
}
|
| 847 |
}
|
| 848 |
|
| 849 |
+
function readErrorFromJsonText(text) {
|
| 850 |
+
let result;
|
| 851 |
+
try {
|
| 852 |
+
result = parseJsonResponse(text, false, '');
|
| 853 |
+
} catch {
|
| 854 |
+
return undefined;
|
| 855 |
+
}
|
| 856 |
+
if (typeof result?.error === 'string') return result.error;
|
| 857 |
+
if (typeof result?.error?.message === 'string') return result.error.message;
|
| 858 |
+
return undefined;
|
| 859 |
+
}
|
| 860 |
+
|
| 861 |
function parseJsonResponse(text, isOk, url) {
|
| 862 |
if (!text) return null;
|
| 863 |
try {
|
|
|
|
| 877 |
return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling');
|
| 878 |
}
|
| 879 |
|
| 880 |
+
function supportsPageSse(capabilitiesValue) {
|
| 881 |
+
return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true);
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
function shouldUseJobPolling(capabilitiesValue, routeMode) {
|
| 885 |
+
if (routeMode !== 'job') return false;
|
| 886 |
if (!supportsJobPolling(capabilitiesValue)) {
|
| 887 |
+
throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。');
|
| 888 |
+
}
|
| 889 |
+
return true;
|
| 890 |
+
}
|
| 891 |
+
|
| 892 |
+
function shouldUsePageSse(capabilitiesValue, request, routeMode) {
|
| 893 |
+
if (routeMode === 'agent' || routeMode === 'job') return false;
|
| 894 |
+
if (!isPageSseAllowed(request)) {
|
| 895 |
+
if (routeMode === 'page_sse') {
|
| 896 |
+
throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。');
|
| 897 |
}
|
| 898 |
return false;
|
| 899 |
}
|
| 900 |
+
if (routeMode === 'page_sse' || isLargeGenerate(request)) {
|
| 901 |
+
assertPageSseReady(capabilitiesValue);
|
| 902 |
+
return true;
|
| 903 |
+
}
|
| 904 |
+
return false;
|
| 905 |
+
}
|
| 906 |
+
|
| 907 |
+
function isLargeGenerate(request) {
|
| 908 |
+
return readMaxImageEdge(request.size) > 2048;
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
function isPageSseAllowed(request) {
|
| 912 |
+
return request.streaming_strategy !== 'off';
|
| 913 |
}
|
| 914 |
|
| 915 |
function readMaxImageEdge(size) {
|
|
|
|
| 919 |
return Math.max(Number(match[1]), Number(match[2]));
|
| 920 |
}
|
| 921 |
|
| 922 |
+
function createScriptError(code, message) {
|
| 923 |
+
const error = new Error(message);
|
| 924 |
+
error.scriptCode = code;
|
| 925 |
+
return error;
|
| 926 |
+
}
|
| 927 |
+
|
| 928 |
+
function isScriptError(error) {
|
| 929 |
+
return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string');
|
| 930 |
+
}
|
| 931 |
+
|
| 932 |
+
function createPageSseHttpError(status, detail) {
|
| 933 |
+
const message = detail ? `页面 SSE 请求失败,状态码 ${status}:${detail}` : `页面 SSE 请求失败,状态码 ${status}。`;
|
| 934 |
+
const error = new Error(message);
|
| 935 |
+
error.status = status;
|
| 936 |
+
return error;
|
| 937 |
+
}
|
| 938 |
+
|
| 939 |
+
function isPageSseRequestRejected(error) {
|
| 940 |
+
return Boolean(
|
| 941 |
+
error &&
|
| 942 |
+
typeof error === 'object' &&
|
| 943 |
+
Number.isInteger(error.status) &&
|
| 944 |
+
error.status >= 400 &&
|
| 945 |
+
error.status < 500
|
| 946 |
+
);
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
function printUsage() {
|
| 950 |
console.error('用法:generate-image.mjs [options] <prompt>');
|
| 951 |
console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
|
| 952 |
+
console.error(
|
| 953 |
+
'常用参数:--model --size --quality --n --format --response-mode --image-backend --streaming-strategy --partial-images --timeout-ms --prompt-file --idempotency-key --page-sse --agent --job --no-job(兼容别名)'
|
| 954 |
+
);
|
| 955 |
console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check');
|
| 956 |
}
|
skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
const MAX_RETRY_AFTER_SECONDS = 60;
|
| 2 |
|
| 3 |
export function readOptionValue(argv, index, name) {
|
|
@@ -9,15 +14,9 @@ export function readOptionValue(argv, index, name) {
|
|
| 9 |
}
|
| 10 |
|
| 11 |
export function readConfiguredPositiveInteger(value, name, fallback) {
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
}
|
| 16 |
-
const parsed = Number(value);
|
| 17 |
-
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
| 18 |
-
throw new Error(`${name} 必须是正整数。`);
|
| 19 |
-
}
|
| 20 |
-
return parsed;
|
| 21 |
}
|
| 22 |
|
| 23 |
export function normalizeBaseUrl(value) {
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
CHINESE_POSITIVE_INTEGER_MESSAGES,
|
| 3 |
+
parsePositiveIntegerConfig
|
| 4 |
+
} from '../../../../src/lib/positive-integer-config.mjs';
|
| 5 |
+
|
| 6 |
const MAX_RETRY_AFTER_SECONDS = 60;
|
| 7 |
|
| 8 |
export function readOptionValue(argv, index, name) {
|
|
|
|
| 14 |
}
|
| 15 |
|
| 16 |
export function readConfiguredPositiveInteger(value, name, fallback) {
|
| 17 |
+
return parsePositiveIntegerConfig(value, name, fallback, {
|
| 18 |
+
messages: CHINESE_POSITIVE_INTEGER_MESSAGES
|
| 19 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
export function normalizeBaseUrl(value) {
|
src/app/api/agent/agent-routes.test.ts
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import assert from 'node:assert/strict';
|
| 2 |
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
| 3 |
import http from 'node:http';
|
|
|
|
| 4 |
import os from 'node:os';
|
| 5 |
import path from 'node:path';
|
| 6 |
import { afterEach, beforeEach, describe, it } from 'node:test';
|
| 7 |
-
import Database from 'better-sqlite3';
|
| 8 |
import { Pool } from 'pg';
|
| 9 |
|
| 10 |
-
const PNG_BASE64 =
|
| 11 |
-
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
| 12 |
|
| 13 |
let originalEnv: NodeJS.ProcessEnv;
|
| 14 |
let originalCwd = '';
|
|
@@ -41,7 +43,9 @@ beforeEach(async () => {
|
|
| 41 |
});
|
| 42 |
|
| 43 |
afterEach(async () => {
|
| 44 |
-
const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import(
|
|
|
|
|
|
|
| 45 |
const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router');
|
| 46 |
restoreProcessEnv(originalEnv);
|
| 47 |
process.chdir(originalCwd);
|
|
@@ -80,6 +84,23 @@ describe('Agent route integration', () => {
|
|
| 80 |
assert.deepEqual(body.auth.schemes, []);
|
| 81 |
});
|
| 82 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
|
| 84 |
const { generateImage } = await loadAgentRoutes();
|
| 85 |
let upstreamCalls = 0;
|
|
@@ -113,6 +134,371 @@ describe('Agent route integration', () => {
|
|
| 113 |
await upstream.close();
|
| 114 |
});
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
it('generates and replays through the memory state backend without creating SQLite state', async () => {
|
| 117 |
process.env.AGENT_STATE_BACKEND = 'memory';
|
| 118 |
delete process.env.AGENT_SQLITE_PATH;
|
|
@@ -126,12 +512,16 @@ describe('Agent route integration', () => {
|
|
| 126 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 127 |
|
| 128 |
try {
|
| 129 |
-
const first = await generateImage(
|
|
|
|
|
|
|
| 130 |
assert.equal(first.status, 200);
|
| 131 |
const firstBody = await first.json();
|
| 132 |
assert.equal(firstBody.cached, false);
|
| 133 |
|
| 134 |
-
const second = await generateImage(
|
|
|
|
|
|
|
| 135 |
assert.equal(second.status, 200);
|
| 136 |
const secondBody = await second.json();
|
| 137 |
assert.equal(secondBody.cached, true);
|
|
@@ -251,6 +641,7 @@ describe('Agent route integration', () => {
|
|
| 251 |
const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' }));
|
| 252 |
assert.equal(replay.status, 502);
|
| 253 |
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
|
|
|
| 254 |
const replayBody = await replay.json();
|
| 255 |
assert.equal(replayBody.error.code, 'upstream_unavailable');
|
| 256 |
assert.equal(replayBody.error.retryable, false);
|
|
@@ -276,7 +667,9 @@ describe('Agent route integration', () => {
|
|
| 276 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 277 |
|
| 278 |
try {
|
| 279 |
-
const created = await createGenerateJob(
|
|
|
|
|
|
|
| 280 |
assert.equal(created.status, 202);
|
| 281 |
const createdBody = await created.json();
|
| 282 |
assert.equal(createdBody.job.state, 'running');
|
|
@@ -304,6 +697,259 @@ describe('Agent route integration', () => {
|
|
| 304 |
}
|
| 305 |
});
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
it('reuses the running generate job for the same idempotency key', async () => {
|
| 308 |
const { createGenerateJob, getJobResult } = await loadAgentRoutes();
|
| 309 |
let releaseUpstream: (() => void) | undefined;
|
|
@@ -356,12 +1002,16 @@ describe('Agent route integration', () => {
|
|
| 356 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 357 |
|
| 358 |
try {
|
| 359 |
-
const first = await createGenerateJob(
|
|
|
|
|
|
|
| 360 |
assert.equal(first.status, 202);
|
| 361 |
const firstBody = await first.json();
|
| 362 |
await waitFor(() => upstreamCalls === 1);
|
| 363 |
|
| 364 |
-
const conflict = await createGenerateJob(
|
|
|
|
|
|
|
| 365 |
assert.equal(conflict.status, 409);
|
| 366 |
assert.equal((await conflict.json()).error.code, 'idempotency_conflict');
|
| 367 |
assert.equal(upstreamCalls, 1);
|
|
@@ -386,7 +1036,9 @@ describe('Agent route integration', () => {
|
|
| 386 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 387 |
|
| 388 |
try {
|
| 389 |
-
const created = await createGenerateJob(
|
|
|
|
|
|
|
| 390 |
assert.equal(created.status, 202);
|
| 391 |
const createdBody = await created.json();
|
| 392 |
|
|
@@ -411,11 +1063,123 @@ describe('Agent route integration', () => {
|
|
| 411 |
assert.equal(statusBody.job.error.upstream_status, 500);
|
| 412 |
assert.equal(statusBody.job.error.diagnostics.upstream_status, 500);
|
| 413 |
assert.equal(upstreamCalls > 0, true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
} finally {
|
| 415 |
await upstream.close();
|
| 416 |
}
|
| 417 |
});
|
| 418 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 419 |
it('keeps a long-running generate job leased while the upstream call is still active', async () => {
|
| 420 |
process.env.AGENT_REQUEST_LEASE_MS = '200';
|
| 421 |
process.env.AGENT_RECOVERY_INTERVAL_MS = '50';
|
|
@@ -433,7 +1197,9 @@ describe('Agent route integration', () => {
|
|
| 433 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 434 |
|
| 435 |
try {
|
| 436 |
-
const created = await createGenerateJob(
|
|
|
|
|
|
|
| 437 |
assert.equal(created.status, 202);
|
| 438 |
const createdBody = await created.json();
|
| 439 |
await waitFor(() => upstreamCalls === 1);
|
|
@@ -457,7 +1223,9 @@ describe('Agent route integration', () => {
|
|
| 457 |
|
| 458 |
it('returns structured errors for missing and expired jobs', async () => {
|
| 459 |
const { getJob, getJobResult } = await loadAgentRoutes();
|
| 460 |
-
const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import(
|
|
|
|
|
|
|
| 461 |
|
| 462 |
const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), {
|
| 463 |
params: Promise.resolve({ id: 'missing-job' })
|
|
@@ -543,13 +1311,45 @@ describe('Agent route integration', () => {
|
|
| 543 |
await upstream.close();
|
| 544 |
});
|
| 545 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
it('returns field-level errors for invalid edit multipart requests', async () => {
|
| 547 |
const { editImage } = await loadAgentRoutes();
|
| 548 |
const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
| 549 |
process.env.OPENAI_API_KEY = 'test-key';
|
| 550 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 551 |
|
| 552 |
-
const response = await editImage(
|
|
|
|
|
|
|
| 553 |
|
| 554 |
assert.equal(response.status, 422);
|
| 555 |
const body = await response.json();
|
|
@@ -559,6 +1359,93 @@ describe('Agent route integration', () => {
|
|
| 559 |
await upstream.close();
|
| 560 |
});
|
| 561 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 562 |
it('returns validation errors for non-multipart edit requests', async () => {
|
| 563 |
const { editImage } = await loadAgentRoutes();
|
| 564 |
|
|
@@ -627,6 +1514,7 @@ describe('Agent route integration', () => {
|
|
| 627 |
const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics'));
|
| 628 |
assert.equal(replay.status, 502);
|
| 629 |
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
|
|
|
| 630 |
const replayBody = await replay.json();
|
| 631 |
assert.equal(replayBody.error.code, 'upstream_unavailable');
|
| 632 |
assert.equal(replayBody.error.retryable, false);
|
|
@@ -703,7 +1591,9 @@ describe('Agent route integration', () => {
|
|
| 703 |
const originalConsoleError = console.error;
|
| 704 |
console.error = () => {};
|
| 705 |
try {
|
| 706 |
-
const response = await editImage(
|
|
|
|
|
|
|
| 707 |
|
| 708 |
assert.equal(response.status, 500);
|
| 709 |
const body = await response.json();
|
|
@@ -727,14 +1617,21 @@ describe('Agent route integration', () => {
|
|
| 727 |
process.env.AGENT_API_TOKEN = 'artifact-token';
|
| 728 |
|
| 729 |
const generated = await generateImage(
|
| 730 |
-
agentJsonRequest(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
);
|
| 732 |
const body = await generated.json();
|
| 733 |
const artifactId = body.images[0].id;
|
| 734 |
|
| 735 |
-
const denied = await getArtifactContent(
|
| 736 |
-
|
| 737 |
-
|
|
|
|
|
|
|
|
|
|
| 738 |
assert.equal(denied.status, 401);
|
| 739 |
assert.equal((await denied.json()).error.code, 'unauthorized');
|
| 740 |
|
|
@@ -770,7 +1667,11 @@ describe('Agent route integration', () => {
|
|
| 770 |
assert.equal((await deleted.json()).deleted, true);
|
| 771 |
|
| 772 |
const replayAfterDelete = await generateImage(
|
| 773 |
-
agentJsonRequest(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
);
|
| 775 |
assert.equal(replayAfterDelete.status, 404);
|
| 776 |
assert.equal((await replayAfterDelete.json()).error.code, 'artifact_not_found');
|
|
@@ -785,7 +1686,9 @@ describe('Agent route integration', () => {
|
|
| 785 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 786 |
|
| 787 |
try {
|
| 788 |
-
const generated = await generateImage(
|
|
|
|
|
|
|
| 789 |
const body = await generated.json();
|
| 790 |
const image = body.images[0];
|
| 791 |
await rm(readStoredArtifactFilepath(image.id), { force: true });
|
|
@@ -868,7 +1771,9 @@ describe('Agent route integration', () => {
|
|
| 868 |
const originalConsoleError = console.error;
|
| 869 |
console.error = () => {};
|
| 870 |
try {
|
| 871 |
-
const response = await generateImage(
|
|
|
|
|
|
|
| 872 |
|
| 873 |
assert.equal(response.status, 500);
|
| 874 |
const body = await response.json();
|
|
@@ -944,7 +1849,9 @@ describe('Agent route integration', () => {
|
|
| 944 |
const originalConsoleError = console.error;
|
| 945 |
console.error = () => {};
|
| 946 |
try {
|
| 947 |
-
const response = await generateImage(
|
|
|
|
|
|
|
| 948 |
|
| 949 |
assert.equal(response.status, 500);
|
| 950 |
const body = await response.json();
|
|
@@ -961,57 +1868,67 @@ describe('Agent route integration', () => {
|
|
| 961 |
|
| 962 |
const livePostgresUrl = process.env.AGENT_POSTGRES_TEST_DATABASE_URL;
|
| 963 |
|
| 964 |
-
describe(
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 980 |
});
|
| 981 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 982 |
});
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
try {
|
| 987 |
-
await admin.query(`CREATE SCHEMA "${schemaName}"`);
|
| 988 |
-
const firstRequest = generateImage(agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }));
|
| 989 |
-
await waitFor(() => upstreamCalls === 1);
|
| 990 |
-
|
| 991 |
-
const second = await generateImage(agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }));
|
| 992 |
-
assert.equal(second.status, 409);
|
| 993 |
-
const secondBody = await second.json();
|
| 994 |
-
assert.equal(secondBody.error.code, 'request_in_progress');
|
| 995 |
-
assert.equal(secondBody.error.retryable, true);
|
| 996 |
-
assert.equal(second.headers.has('retry-after'), true);
|
| 997 |
-
|
| 998 |
-
releaseUpstream?.();
|
| 999 |
-
const first = await firstRequest;
|
| 1000 |
-
assert.equal(first.status, 200);
|
| 1001 |
-
|
| 1002 |
-
const replay = await generateImage(agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }));
|
| 1003 |
-
assert.equal(replay.status, 200);
|
| 1004 |
-
assert.equal((await replay.json()).cached, true);
|
| 1005 |
-
assert.equal(upstreamCalls, 1);
|
| 1006 |
-
} finally {
|
| 1007 |
-
releaseUpstream?.();
|
| 1008 |
-
await upstream.close();
|
| 1009 |
-
await admin.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
| 1010 |
-
admin.release();
|
| 1011 |
-
await pool.end();
|
| 1012 |
-
}
|
| 1013 |
-
});
|
| 1014 |
-
});
|
| 1015 |
|
| 1016 |
async function loadAgentRoutes() {
|
| 1017 |
const { resetAgentStateStoreForTests } = await import('@/lib/agent-state-runtime');
|
|
@@ -1027,18 +1944,28 @@ async function loadAgentRoutes() {
|
|
| 1027 |
const jobRoute = await import('./jobs/[id]/route');
|
| 1028 |
const jobResultRoute = await import('./jobs/[id]/result/route');
|
| 1029 |
return {
|
| 1030 |
-
getCapabilities: capabilitiesRoute.GET,
|
| 1031 |
-
generateImage: generateRoute.POST,
|
| 1032 |
-
editImage: editRoute.POST,
|
| 1033 |
-
createGenerateJob: createGenerateJobRoute.POST,
|
| 1034 |
-
getJob: jobRoute.GET,
|
| 1035 |
-
getJobResult:
|
| 1036 |
-
|
| 1037 |
-
|
| 1038 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1039 |
};
|
| 1040 |
}
|
| 1041 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1042 |
function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
|
| 1043 |
return new Request('http://localhost/api/agent/images/generate', {
|
| 1044 |
method: 'POST',
|
|
@@ -1051,7 +1978,11 @@ function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>,
|
|
| 1051 |
});
|
| 1052 |
}
|
| 1053 |
|
| 1054 |
-
function agentJobJsonRequest(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1055 |
return new Request('http://localhost/api/agent/jobs/images/generate', {
|
| 1056 |
method: 'POST',
|
| 1057 |
headers: {
|
|
@@ -1063,28 +1994,55 @@ function agentJobJsonRequest(idempotencyKey: string, body: Record<string, unknow
|
|
| 1063 |
});
|
| 1064 |
}
|
| 1065 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1066 |
function agentEditRequest(
|
| 1067 |
idempotencyKey: string,
|
| 1068 |
prompt: string,
|
| 1069 |
headers: Record<string, string> = {},
|
| 1070 |
-
|
|
|
|
| 1071 |
) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1072 |
const formData = new FormData();
|
| 1073 |
formData.append('prompt', prompt);
|
| 1074 |
formData.append('model', 'gpt-image-2');
|
| 1075 |
-
|
| 1076 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1077 |
return new Request('http://localhost/api/agent/images/edit', {
|
| 1078 |
method: 'POST',
|
| 1079 |
headers: {
|
| 1080 |
'Idempotency-Key': idempotencyKey,
|
| 1081 |
...headers
|
| 1082 |
},
|
| 1083 |
-
body: formData
|
|
|
|
| 1084 |
});
|
| 1085 |
}
|
| 1086 |
|
| 1087 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
const server = http.createServer(async (request, response) => {
|
| 1089 |
if (
|
| 1090 |
request.method !== 'POST' ||
|
|
@@ -1094,9 +2052,11 @@ async function startImageUpstream(handler: () => unknown | Promise<unknown>): Pr
|
|
| 1094 |
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 1095 |
return;
|
| 1096 |
}
|
| 1097 |
-
|
|
|
|
|
|
|
| 1098 |
try {
|
| 1099 |
-
const body = await handler();
|
| 1100 |
response.writeHead(200, { 'Content-Type': 'application/json' });
|
| 1101 |
response.end(JSON.stringify(body));
|
| 1102 |
} catch (error) {
|
|
@@ -1114,6 +2074,111 @@ async function startImageUpstream(handler: () => unknown | Promise<unknown>): Pr
|
|
| 1114 |
};
|
| 1115 |
}
|
| 1116 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1117 |
function readStoredResponseJson(idempotencyKey: string): string {
|
| 1118 |
const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true });
|
| 1119 |
try {
|
|
@@ -1130,7 +2195,9 @@ function readStoredResponseJson(idempotencyKey: string): string {
|
|
| 1130 |
function readStoredArtifactFilepath(id: string): string {
|
| 1131 |
const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true });
|
| 1132 |
try {
|
| 1133 |
-
const row = db.prepare('SELECT filepath FROM agent_artifacts WHERE id = ?').get(id) as
|
|
|
|
|
|
|
| 1134 |
assert.ok(row);
|
| 1135 |
return row.filepath;
|
| 1136 |
} finally {
|
|
@@ -1140,7 +2207,9 @@ function readStoredArtifactFilepath(id: string): string {
|
|
| 1140 |
|
| 1141 |
async function listGeneratedImageFiles(): Promise<string[]> {
|
| 1142 |
try {
|
| 1143 |
-
return (await readdir(path.join(tempDir, 'generated-images'))).filter((entry) =>
|
|
|
|
|
|
|
| 1144 |
} catch {
|
| 1145 |
return [];
|
| 1146 |
}
|
|
@@ -1163,10 +2232,7 @@ async function waitFor(predicate: () => boolean): Promise<void> {
|
|
| 1163 |
}
|
| 1164 |
|
| 1165 |
async function waitForJobResult(
|
| 1166 |
-
getJobResult: (
|
| 1167 |
-
request: Request,
|
| 1168 |
-
context: { params: Promise<{ id: string }> }
|
| 1169 |
-
) => Promise<Response>,
|
| 1170 |
id: string
|
| 1171 |
): Promise<Response> {
|
| 1172 |
for (let attempt = 0; attempt < 200; attempt += 1) {
|
|
|
|
| 1 |
+
import type { AgentErrorCode } from '@/lib/api-error-response';
|
| 2 |
+
import Database from 'better-sqlite3';
|
| 3 |
+
import type { NextRequest } from 'next/server';
|
| 4 |
import assert from 'node:assert/strict';
|
| 5 |
import { mkdtemp, readdir, rm } from 'node:fs/promises';
|
| 6 |
import http from 'node:http';
|
| 7 |
+
import type { Socket } from 'node:net';
|
| 8 |
import os from 'node:os';
|
| 9 |
import path from 'node:path';
|
| 10 |
import { afterEach, beforeEach, describe, it } from 'node:test';
|
|
|
|
| 11 |
import { Pool } from 'pg';
|
| 12 |
|
| 13 |
+
const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
|
|
|
| 14 |
|
| 15 |
let originalEnv: NodeJS.ProcessEnv;
|
| 16 |
let originalCwd = '';
|
|
|
|
| 43 |
});
|
| 44 |
|
| 45 |
afterEach(async () => {
|
| 46 |
+
const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import(
|
| 47 |
+
'@/lib/agent-state-runtime'
|
| 48 |
+
);
|
| 49 |
const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router');
|
| 50 |
restoreProcessEnv(originalEnv);
|
| 51 |
process.chdir(originalCwd);
|
|
|
|
| 84 |
assert.deepEqual(body.auth.schemes, []);
|
| 85 |
});
|
| 86 |
|
| 87 |
+
it('reports enabled Responses image backend from the deployed runtime environment', async () => {
|
| 88 |
+
const { getCapabilities } = await loadAgentRoutes();
|
| 89 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 90 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 91 |
+
|
| 92 |
+
const response = await getCapabilities();
|
| 93 |
+
assert.equal(response.status, 200);
|
| 94 |
+
const body = await response.json();
|
| 95 |
+
assert.deepEqual(body.supported.enabled_image_backends, ['images-api', 'responses-image-generation']);
|
| 96 |
+
assert.equal(body.supported.image_backend_requirements['responses-image-generation'].enabled, true);
|
| 97 |
+
assert.deepEqual(body.supported.image_backend_requirements['responses-image-generation'].missing_env, []);
|
| 98 |
+
assert.deepEqual(body.agent_streaming.upstream_sse.enabled_image_backends, [
|
| 99 |
+
'images-api',
|
| 100 |
+
'responses-image-generation'
|
| 101 |
+
]);
|
| 102 |
+
});
|
| 103 |
+
|
| 104 |
it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
|
| 105 |
const { generateImage } = await loadAgentRoutes();
|
| 106 |
let upstreamCalls = 0;
|
|
|
|
| 134 |
await upstream.close();
|
| 135 |
});
|
| 136 |
|
| 137 |
+
it('does not send upstream stream parameters when Agent streaming_strategy is off', async () => {
|
| 138 |
+
const { generateImage } = await loadAgentRoutes();
|
| 139 |
+
let upstreamBody = '';
|
| 140 |
+
const upstream = await startImageUpstream((body) => {
|
| 141 |
+
upstreamBody = body;
|
| 142 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 143 |
+
});
|
| 144 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 145 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 146 |
+
|
| 147 |
+
try {
|
| 148 |
+
const response = await generateImage(
|
| 149 |
+
agentJsonRequest('agent-upstream-stream-off-key', {
|
| 150 |
+
prompt: 'agent upstream stream off',
|
| 151 |
+
streaming_strategy: 'off'
|
| 152 |
+
})
|
| 153 |
+
);
|
| 154 |
+
|
| 155 |
+
assert.equal(response.status, 200);
|
| 156 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 157 |
+
assert.equal(upstreamJson.stream, false);
|
| 158 |
+
assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
|
| 159 |
+
} finally {
|
| 160 |
+
await upstream.close();
|
| 161 |
+
}
|
| 162 |
+
});
|
| 163 |
+
|
| 164 |
+
it('consumes upstream image SSE internally while keeping the Agent generate response non-streaming', async () => {
|
| 165 |
+
const { generateImage } = await loadAgentRoutes();
|
| 166 |
+
let upstreamBody = '';
|
| 167 |
+
const upstream = await startStreamingImageUpstream((body) => {
|
| 168 |
+
upstreamBody = body;
|
| 169 |
+
return [
|
| 170 |
+
{
|
| 171 |
+
event: 'image_generation.partial_image',
|
| 172 |
+
data: { type: 'image_generation.partial_image', b64_json: 'agent-partial-base64' }
|
| 173 |
+
},
|
| 174 |
+
{
|
| 175 |
+
event: 'image_generation.completed',
|
| 176 |
+
data: { type: 'image_generation.completed', b64_json: PNG_BASE64 }
|
| 177 |
+
}
|
| 178 |
+
];
|
| 179 |
+
});
|
| 180 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 181 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 182 |
+
|
| 183 |
+
try {
|
| 184 |
+
const response = await generateImage(
|
| 185 |
+
agentJsonRequest('agent-upstream-sse-key', {
|
| 186 |
+
prompt: 'agent upstream sse',
|
| 187 |
+
response_mode: 'base64',
|
| 188 |
+
streaming_strategy: 'newapi-keepalive-sse',
|
| 189 |
+
partial_images: 2
|
| 190 |
+
})
|
| 191 |
+
);
|
| 192 |
+
|
| 193 |
+
assert.equal(response.status, 200);
|
| 194 |
+
assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
|
| 195 |
+
const body = await response.json();
|
| 196 |
+
assert.equal(body.cached, false);
|
| 197 |
+
assert.equal(body.images[0].b64_json, PNG_BASE64);
|
| 198 |
+
|
| 199 |
+
const upstreamJson = JSON.parse(upstreamBody);
|
| 200 |
+
assert.equal(upstreamJson.stream, true);
|
| 201 |
+
assert.equal(upstreamJson.partial_images, 2);
|
| 202 |
+
} finally {
|
| 203 |
+
await upstream.close();
|
| 204 |
+
}
|
| 205 |
+
});
|
| 206 |
+
|
| 207 |
+
it('consumes JSON Images responses returned to Agent stream requests as final results', async () => {
|
| 208 |
+
const { generateImage } = await loadAgentRoutes();
|
| 209 |
+
let upstreamBody = '';
|
| 210 |
+
const upstream = await startImageUpstream((body) => {
|
| 211 |
+
upstreamBody = body;
|
| 212 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 213 |
+
});
|
| 214 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 215 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 216 |
+
|
| 217 |
+
try {
|
| 218 |
+
const response = await generateImage(
|
| 219 |
+
agentJsonRequest('agent-stream-json-fallback-key', {
|
| 220 |
+
prompt: 'agent stream json fallback',
|
| 221 |
+
response_mode: 'base64',
|
| 222 |
+
streaming_strategy: 'newapi-keepalive-sse',
|
| 223 |
+
partial_images: 2
|
| 224 |
+
})
|
| 225 |
+
);
|
| 226 |
+
|
| 227 |
+
assert.equal(response.status, 200);
|
| 228 |
+
assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
|
| 229 |
+
const body = await response.json();
|
| 230 |
+
assert.equal(body.images[0].b64_json, PNG_BASE64);
|
| 231 |
+
|
| 232 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 233 |
+
assert.equal(upstreamJson.stream, true);
|
| 234 |
+
assert.equal(upstreamJson.partial_images, 2);
|
| 235 |
+
} finally {
|
| 236 |
+
await upstream.close();
|
| 237 |
+
}
|
| 238 |
+
});
|
| 239 |
+
|
| 240 |
+
it('uses force-sse for Agent upstream image SSE while keeping the final JSON contract', async () => {
|
| 241 |
+
const { generateImage } = await loadAgentRoutes();
|
| 242 |
+
let upstreamBody = '';
|
| 243 |
+
const upstream = await startStreamingImageUpstream((body) => {
|
| 244 |
+
upstreamBody = body;
|
| 245 |
+
return [
|
| 246 |
+
{
|
| 247 |
+
event: 'image_generation.completed',
|
| 248 |
+
data: { type: 'image_generation.completed', b64_json: PNG_BASE64 }
|
| 249 |
+
}
|
| 250 |
+
];
|
| 251 |
+
});
|
| 252 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 253 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 254 |
+
|
| 255 |
+
try {
|
| 256 |
+
const response = await generateImage(
|
| 257 |
+
agentJsonRequest('agent-force-sse-key', {
|
| 258 |
+
prompt: 'agent force sse',
|
| 259 |
+
response_mode: 'base64',
|
| 260 |
+
streaming_strategy: 'force-sse',
|
| 261 |
+
partial_images: 3
|
| 262 |
+
})
|
| 263 |
+
);
|
| 264 |
+
|
| 265 |
+
assert.equal(response.status, 200);
|
| 266 |
+
assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
|
| 267 |
+
const body = await response.json();
|
| 268 |
+
assert.equal(body.images[0].b64_json, PNG_BASE64);
|
| 269 |
+
|
| 270 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 271 |
+
assert.equal(upstreamJson.stream, true);
|
| 272 |
+
assert.equal(upstreamJson.partial_images, 3);
|
| 273 |
+
} finally {
|
| 274 |
+
await upstream.close();
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
|
| 278 |
+
it('consumes Responses image_generation SSE internally while keeping the Agent generate response non-streaming', async () => {
|
| 279 |
+
const { generateImage } = await loadAgentRoutes();
|
| 280 |
+
let upstreamBody = '';
|
| 281 |
+
const upstream = await startStreamingResponsesImageUpstream((body) => {
|
| 282 |
+
upstreamBody = body;
|
| 283 |
+
return [
|
| 284 |
+
{
|
| 285 |
+
event: 'response.image_generation_call.partial_image',
|
| 286 |
+
data: {
|
| 287 |
+
type: 'response.image_generation_call.partial_image',
|
| 288 |
+
partial_image_b64: 'agent-responses-partial-base64',
|
| 289 |
+
partial_image_index: 0
|
| 290 |
+
}
|
| 291 |
+
},
|
| 292 |
+
{
|
| 293 |
+
event: 'response.output_item.done',
|
| 294 |
+
data: {
|
| 295 |
+
type: 'response.output_item.done',
|
| 296 |
+
item: {
|
| 297 |
+
type: 'image_generation_call',
|
| 298 |
+
status: 'completed',
|
| 299 |
+
result: PNG_BASE64
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
];
|
| 304 |
+
});
|
| 305 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 306 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 307 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 308 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 309 |
+
|
| 310 |
+
try {
|
| 311 |
+
const response = await generateImage(
|
| 312 |
+
agentJsonRequest('agent-responses-upstream-sse-key', {
|
| 313 |
+
prompt: 'agent responses upstream sse',
|
| 314 |
+
response_mode: 'base64',
|
| 315 |
+
image_backend: 'responses-image-generation',
|
| 316 |
+
streaming_strategy: 'responses-sse',
|
| 317 |
+
partial_images: 2
|
| 318 |
+
})
|
| 319 |
+
);
|
| 320 |
+
|
| 321 |
+
assert.equal(response.status, 200);
|
| 322 |
+
assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
|
| 323 |
+
const body = await response.json();
|
| 324 |
+
assert.equal(body.cached, false);
|
| 325 |
+
assert.equal(body.images[0].b64_json, PNG_BASE64);
|
| 326 |
+
|
| 327 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 328 |
+
assert.equal(upstreamJson.model, 'gpt-5.4');
|
| 329 |
+
assert.equal(upstreamJson.stream, true);
|
| 330 |
+
const tools = upstreamJson.tools as Array<Record<string, unknown>>;
|
| 331 |
+
assert.equal(tools[0].type, 'image_generation');
|
| 332 |
+
assert.equal(tools[0].partial_images, 2);
|
| 333 |
+
} finally {
|
| 334 |
+
await upstream.close();
|
| 335 |
+
}
|
| 336 |
+
});
|
| 337 |
+
|
| 338 |
+
it('uses force-sse for Agent Responses image_generation SSE while keeping the final JSON contract', async () => {
|
| 339 |
+
const { generateImage } = await loadAgentRoutes();
|
| 340 |
+
let upstreamBody = '';
|
| 341 |
+
const upstream = await startStreamingResponsesImageUpstream((body) => {
|
| 342 |
+
upstreamBody = body;
|
| 343 |
+
return [
|
| 344 |
+
{
|
| 345 |
+
event: 'response.output_item.done',
|
| 346 |
+
data: {
|
| 347 |
+
type: 'response.output_item.done',
|
| 348 |
+
item: {
|
| 349 |
+
type: 'image_generation_call',
|
| 350 |
+
status: 'completed',
|
| 351 |
+
result: PNG_BASE64
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
];
|
| 356 |
+
});
|
| 357 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 358 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 359 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 360 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 361 |
+
|
| 362 |
+
try {
|
| 363 |
+
const response = await generateImage(
|
| 364 |
+
agentJsonRequest('agent-responses-force-sse-key', {
|
| 365 |
+
prompt: 'agent responses force sse',
|
| 366 |
+
response_mode: 'base64',
|
| 367 |
+
image_backend: 'responses-image-generation',
|
| 368 |
+
streaming_strategy: 'force-sse',
|
| 369 |
+
partial_images: 3
|
| 370 |
+
})
|
| 371 |
+
);
|
| 372 |
+
|
| 373 |
+
assert.equal(response.status, 200);
|
| 374 |
+
assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
|
| 375 |
+
const body = await response.json();
|
| 376 |
+
assert.equal(body.images[0].b64_json, PNG_BASE64);
|
| 377 |
+
|
| 378 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 379 |
+
assert.equal(upstreamJson.model, 'gpt-5.4');
|
| 380 |
+
assert.equal(upstreamJson.stream, true);
|
| 381 |
+
const tools = upstreamJson.tools as Array<Record<string, unknown>>;
|
| 382 |
+
assert.equal(tools[0].type, 'image_generation');
|
| 383 |
+
assert.equal(tools[0].partial_images, 3);
|
| 384 |
+
} finally {
|
| 385 |
+
await upstream.close();
|
| 386 |
+
}
|
| 387 |
+
});
|
| 388 |
+
|
| 389 |
+
it('fails Agent Responses upstream SSE requests when partial images arrive without a final image', async () => {
|
| 390 |
+
const { generateImage } = await loadAgentRoutes();
|
| 391 |
+
const upstream = await startStreamingResponsesImageUpstream(() => [
|
| 392 |
+
{
|
| 393 |
+
event: 'response.image_generation_call.partial_image',
|
| 394 |
+
data: {
|
| 395 |
+
type: 'response.image_generation_call.partial_image',
|
| 396 |
+
partial_image_b64: 'agent-responses-partial-only',
|
| 397 |
+
partial_image_index: 0
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
]);
|
| 401 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 402 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 403 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 404 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 405 |
+
|
| 406 |
+
try {
|
| 407 |
+
const response = await generateImage(
|
| 408 |
+
agentJsonRequest('agent-responses-upstream-sse-partial-only-key', {
|
| 409 |
+
prompt: 'agent responses upstream sse partial only',
|
| 410 |
+
image_backend: 'responses-image-generation',
|
| 411 |
+
streaming_strategy: 'responses-sse',
|
| 412 |
+
partial_images: 2
|
| 413 |
+
})
|
| 414 |
+
);
|
| 415 |
+
|
| 416 |
+
assert.equal(response.status, 502);
|
| 417 |
+
const body = await response.json();
|
| 418 |
+
assert.equal(body.error.code, 'upstream_unavailable');
|
| 419 |
+
assert.match(body.error.message, /最终图片 b64_json/);
|
| 420 |
+
assert.equal(body.error.diagnostics.upstream_event_type, 'response.image_generation_call.partial_image');
|
| 421 |
+
assert.equal(body.error.diagnostics.partial_image_count, 1);
|
| 422 |
+
assert.equal(body.error.diagnostics.retry_after_seconds, 15);
|
| 423 |
+
assert.equal(JSON.stringify(body).includes('agent-responses-partial-only'), false);
|
| 424 |
+
} finally {
|
| 425 |
+
await upstream.close();
|
| 426 |
+
}
|
| 427 |
+
});
|
| 428 |
+
|
| 429 |
+
it('fails Agent Responses upstream SSE requests when the image_generation_call fails', async () => {
|
| 430 |
+
const { generateImage } = await loadAgentRoutes();
|
| 431 |
+
const upstream = await startStreamingResponsesImageUpstream(() => [
|
| 432 |
+
{
|
| 433 |
+
event: 'response.output_item.done',
|
| 434 |
+
data: {
|
| 435 |
+
type: 'response.output_item.done',
|
| 436 |
+
item: {
|
| 437 |
+
type: 'image_generation_call',
|
| 438 |
+
status: 'failed',
|
| 439 |
+
error: {
|
| 440 |
+
code: 'content_policy_violation',
|
| 441 |
+
message: 'blocked by upstream policy'
|
| 442 |
+
}
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
}
|
| 446 |
+
]);
|
| 447 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 448 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 449 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 450 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 451 |
+
|
| 452 |
+
try {
|
| 453 |
+
const response = await generateImage(
|
| 454 |
+
agentJsonRequest('agent-responses-upstream-sse-failed-call-key', {
|
| 455 |
+
prompt: 'agent responses upstream sse failed call',
|
| 456 |
+
image_backend: 'responses-image-generation',
|
| 457 |
+
streaming_strategy: 'responses-sse',
|
| 458 |
+
partial_images: 2
|
| 459 |
+
})
|
| 460 |
+
);
|
| 461 |
+
|
| 462 |
+
assert.equal(response.status, 502);
|
| 463 |
+
const body = await response.json();
|
| 464 |
+
assert.equal(body.error.code, 'upstream_unavailable');
|
| 465 |
+
assert.match(body.error.message, /blocked by upstream policy/);
|
| 466 |
+
} finally {
|
| 467 |
+
await upstream.close();
|
| 468 |
+
}
|
| 469 |
+
});
|
| 470 |
+
|
| 471 |
+
it('fails Agent upstream SSE requests when partial images arrive without a final image', async () => {
|
| 472 |
+
const { generateImage } = await loadAgentRoutes();
|
| 473 |
+
const upstream = await startStreamingImageUpstream(() => [
|
| 474 |
+
{
|
| 475 |
+
event: 'image_generation.partial_image',
|
| 476 |
+
data: { type: 'image_generation.partial_image', b64_json: 'agent-partial-only' }
|
| 477 |
+
}
|
| 478 |
+
]);
|
| 479 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 480 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 481 |
+
|
| 482 |
+
try {
|
| 483 |
+
const response = await generateImage(
|
| 484 |
+
agentJsonRequest('agent-upstream-sse-partial-only-key', {
|
| 485 |
+
prompt: 'agent upstream sse partial only',
|
| 486 |
+
streaming_strategy: 'newapi-keepalive-sse',
|
| 487 |
+
partial_images: 2
|
| 488 |
+
})
|
| 489 |
+
);
|
| 490 |
+
|
| 491 |
+
assert.equal(response.status, 502);
|
| 492 |
+
const body = await response.json();
|
| 493 |
+
assert.equal(body.error.code, 'upstream_unavailable');
|
| 494 |
+
assert.match(body.error.message, /最终图片 b64_json/);
|
| 495 |
+
assert.equal(body.error.diagnostics.upstream_event_type, 'image_generation.partial_image');
|
| 496 |
+
assert.equal(body.error.diagnostics.partial_image_count, 1);
|
| 497 |
+
} finally {
|
| 498 |
+
await upstream.close();
|
| 499 |
+
}
|
| 500 |
+
});
|
| 501 |
+
|
| 502 |
it('generates and replays through the memory state backend without creating SQLite state', async () => {
|
| 503 |
process.env.AGENT_STATE_BACKEND = 'memory';
|
| 504 |
delete process.env.AGENT_SQLITE_PATH;
|
|
|
|
| 512 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 513 |
|
| 514 |
try {
|
| 515 |
+
const first = await generateImage(
|
| 516 |
+
agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' })
|
| 517 |
+
);
|
| 518 |
assert.equal(first.status, 200);
|
| 519 |
const firstBody = await first.json();
|
| 520 |
assert.equal(firstBody.cached, false);
|
| 521 |
|
| 522 |
+
const second = await generateImage(
|
| 523 |
+
agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' })
|
| 524 |
+
);
|
| 525 |
assert.equal(second.status, 200);
|
| 526 |
const secondBody = await second.json();
|
| 527 |
assert.equal(secondBody.cached, true);
|
|
|
|
| 641 |
const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' }));
|
| 642 |
assert.equal(replay.status, 502);
|
| 643 |
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
| 644 |
+
assert.equal(replay.headers.get('x-request-id'), body.error.request_id);
|
| 645 |
const replayBody = await replay.json();
|
| 646 |
assert.equal(replayBody.error.code, 'upstream_unavailable');
|
| 647 |
assert.equal(replayBody.error.retryable, false);
|
|
|
|
| 667 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 668 |
|
| 669 |
try {
|
| 670 |
+
const created = await createGenerateJob(
|
| 671 |
+
agentJobJsonRequest('route-job-key', { prompt: 'agent route job' })
|
| 672 |
+
);
|
| 673 |
assert.equal(created.status, 202);
|
| 674 |
const createdBody = await created.json();
|
| 675 |
assert.equal(createdBody.job.state, 'running');
|
|
|
|
| 697 |
}
|
| 698 |
});
|
| 699 |
|
| 700 |
+
it('creates a generate job that consumes upstream image SSE and saves the final artifact', async () => {
|
| 701 |
+
const { createGenerateJob, getJobResult, getArtifactContent } = await loadAgentRoutes();
|
| 702 |
+
let upstreamBody = '';
|
| 703 |
+
const upstream = await startStreamingImageUpstream((body) => {
|
| 704 |
+
upstreamBody = body;
|
| 705 |
+
return [
|
| 706 |
+
{
|
| 707 |
+
event: 'image_generation.partial_image',
|
| 708 |
+
data: { type: 'image_generation.partial_image', b64_json: 'job-partial-base64' }
|
| 709 |
+
},
|
| 710 |
+
{
|
| 711 |
+
event: 'image_generation.completed',
|
| 712 |
+
data: { type: 'image_generation.completed', b64_json: PNG_BASE64 }
|
| 713 |
+
}
|
| 714 |
+
];
|
| 715 |
+
});
|
| 716 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 717 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 718 |
+
|
| 719 |
+
try {
|
| 720 |
+
const created = await createGenerateJob(
|
| 721 |
+
agentJobJsonRequest('route-job-upstream-sse-key', {
|
| 722 |
+
prompt: 'agent job upstream sse',
|
| 723 |
+
streaming_strategy: 'newapi-keepalive-sse',
|
| 724 |
+
partial_images: 2
|
| 725 |
+
})
|
| 726 |
+
);
|
| 727 |
+
assert.equal(created.status, 202);
|
| 728 |
+
const createdBody = await created.json();
|
| 729 |
+
|
| 730 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 731 |
+
assert.equal(result.status, 200);
|
| 732 |
+
assert.notEqual(result.headers.get('content-type'), 'text/event-stream');
|
| 733 |
+
const resultBody = await result.json();
|
| 734 |
+
assert.equal(resultBody.request_id, createdBody.job.id);
|
| 735 |
+
assert.equal(resultBody.cached, false);
|
| 736 |
+
assert.equal(resultBody.images.length, 1);
|
| 737 |
+
assert.equal('b64_json' in resultBody.images[0], false);
|
| 738 |
+
assert.match(resultBody.images[0].content_url, /^\/api\/agent\/artifacts\/[^/]+\/content$/);
|
| 739 |
+
|
| 740 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 741 |
+
assert.equal(upstreamJson.stream, true);
|
| 742 |
+
assert.equal(upstreamJson.partial_images, 2);
|
| 743 |
+
|
| 744 |
+
const artifactId = resultBody.images[0].content_url.split('/').at(-2);
|
| 745 |
+
assert.equal(typeof artifactId, 'string');
|
| 746 |
+
const content = await getArtifactContent(
|
| 747 |
+
new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`),
|
| 748 |
+
{ params: Promise.resolve({ id: artifactId }) }
|
| 749 |
+
);
|
| 750 |
+
assert.equal(content.status, 200);
|
| 751 |
+
assert.equal(
|
| 752 |
+
Buffer.compare(Buffer.from(await content.arrayBuffer()), Buffer.from(PNG_BASE64, 'base64')),
|
| 753 |
+
0
|
| 754 |
+
);
|
| 755 |
+
} finally {
|
| 756 |
+
await upstream.close();
|
| 757 |
+
}
|
| 758 |
+
});
|
| 759 |
+
|
| 760 |
+
it('fails image upstream SSE generate jobs when partial images arrive without a final image', async () => {
|
| 761 |
+
const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
|
| 762 |
+
const upstream = await startStreamingImageUpstream(() => [
|
| 763 |
+
{
|
| 764 |
+
event: 'image_generation.partial_image',
|
| 765 |
+
data: { type: 'image_generation.partial_image', b64_json: 'job-images-partial-only' }
|
| 766 |
+
}
|
| 767 |
+
]);
|
| 768 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 769 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 770 |
+
|
| 771 |
+
try {
|
| 772 |
+
const created = await createGenerateJob(
|
| 773 |
+
agentJobJsonRequest('route-job-images-missing-final-key', {
|
| 774 |
+
prompt: 'agent job images partial only',
|
| 775 |
+
streaming_strategy: 'newapi-keepalive-sse',
|
| 776 |
+
partial_images: 2
|
| 777 |
+
})
|
| 778 |
+
);
|
| 779 |
+
assert.equal(created.status, 202);
|
| 780 |
+
const createdBody = await created.json();
|
| 781 |
+
|
| 782 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 783 |
+
assert.equal(result.status, 502);
|
| 784 |
+
const resultBody = await result.json();
|
| 785 |
+
assert.equal(resultBody.error.code, 'upstream_unavailable');
|
| 786 |
+
assert.match(resultBody.error.message, /最终图片 b64_json/);
|
| 787 |
+
assert.equal(resultBody.error.retryable, false);
|
| 788 |
+
assert.equal(resultBody.error.diagnostics.upstream_event_type, 'image_generation.partial_image');
|
| 789 |
+
assert.equal(resultBody.error.diagnostics.partial_image_count, 1);
|
| 790 |
+
assert.equal(resultBody.error.diagnostics.retry_after_seconds, undefined);
|
| 791 |
+
assert.equal(JSON.stringify(resultBody).includes('job-images-partial-only'), false);
|
| 792 |
+
|
| 793 |
+
const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
|
| 794 |
+
params: Promise.resolve({ id: createdBody.job.id })
|
| 795 |
+
});
|
| 796 |
+
assert.equal(status.status, 200);
|
| 797 |
+
const statusBody = await status.json();
|
| 798 |
+
assert.equal(statusBody.job.state, 'failed');
|
| 799 |
+
assert.equal(statusBody.job.error.code, 'upstream_unavailable');
|
| 800 |
+
assert.match(statusBody.job.error.message, /最终图片 b64_json/);
|
| 801 |
+
assert.equal(statusBody.job.error.retryable, false);
|
| 802 |
+
assert.equal(statusBody.job.error.diagnostics.upstream_event_type, 'image_generation.partial_image');
|
| 803 |
+
assert.equal(statusBody.job.error.diagnostics.partial_image_count, 1);
|
| 804 |
+
assert.equal(statusBody.job.error.diagnostics.retry_after_seconds, undefined);
|
| 805 |
+
assert.equal(JSON.stringify(statusBody).includes('job-images-partial-only'), false);
|
| 806 |
+
} finally {
|
| 807 |
+
await upstream.close();
|
| 808 |
+
}
|
| 809 |
+
});
|
| 810 |
+
|
| 811 |
+
it('creates a generate job that consumes Responses image_generation SSE and saves the final artifact', async () => {
|
| 812 |
+
const { createGenerateJob, getJobResult, getArtifactContent } = await loadAgentRoutes();
|
| 813 |
+
let upstreamBody = '';
|
| 814 |
+
const upstream = await startStreamingResponsesImageUpstream((body) => {
|
| 815 |
+
upstreamBody = body;
|
| 816 |
+
return [
|
| 817 |
+
{
|
| 818 |
+
event: 'response.image_generation_call.partial_image',
|
| 819 |
+
data: {
|
| 820 |
+
type: 'response.image_generation_call.partial_image',
|
| 821 |
+
partial_image_b64: 'job-responses-partial-base64',
|
| 822 |
+
partial_image_index: 0
|
| 823 |
+
}
|
| 824 |
+
},
|
| 825 |
+
{
|
| 826 |
+
event: 'response.output_item.done',
|
| 827 |
+
data: {
|
| 828 |
+
type: 'response.output_item.done',
|
| 829 |
+
item: {
|
| 830 |
+
type: 'image_generation_call',
|
| 831 |
+
status: 'completed',
|
| 832 |
+
result: PNG_BASE64
|
| 833 |
+
}
|
| 834 |
+
}
|
| 835 |
+
}
|
| 836 |
+
];
|
| 837 |
+
});
|
| 838 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 839 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 840 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 841 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 842 |
+
|
| 843 |
+
try {
|
| 844 |
+
const created = await createGenerateJob(
|
| 845 |
+
agentJobJsonRequest('route-job-responses-upstream-sse-key', {
|
| 846 |
+
prompt: 'agent job responses upstream sse',
|
| 847 |
+
image_backend: 'responses-image-generation',
|
| 848 |
+
streaming_strategy: 'responses-sse',
|
| 849 |
+
partial_images: 2
|
| 850 |
+
})
|
| 851 |
+
);
|
| 852 |
+
assert.equal(created.status, 202);
|
| 853 |
+
const createdBody = await created.json();
|
| 854 |
+
|
| 855 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 856 |
+
assert.equal(result.status, 200);
|
| 857 |
+
assert.notEqual(result.headers.get('content-type'), 'text/event-stream');
|
| 858 |
+
const resultBody = await result.json();
|
| 859 |
+
assert.equal(resultBody.request_id, createdBody.job.id);
|
| 860 |
+
assert.equal(resultBody.cached, false);
|
| 861 |
+
assert.equal(resultBody.images.length, 1);
|
| 862 |
+
assert.equal('b64_json' in resultBody.images[0], false);
|
| 863 |
+
assert.match(resultBody.images[0].content_url, /^\/api\/agent\/artifacts\/[^/]+\/content$/);
|
| 864 |
+
|
| 865 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 866 |
+
assert.equal(upstreamJson.model, 'gpt-5.4');
|
| 867 |
+
assert.equal(upstreamJson.stream, true);
|
| 868 |
+
assert.equal((upstreamJson.tool_choice as Record<string, unknown>).type, 'image_generation');
|
| 869 |
+
const tools = upstreamJson.tools as Array<Record<string, unknown>>;
|
| 870 |
+
assert.equal(tools[0].type, 'image_generation');
|
| 871 |
+
assert.equal(tools[0].partial_images, 2);
|
| 872 |
+
|
| 873 |
+
const artifactId = resultBody.images[0].content_url.split('/').at(-2);
|
| 874 |
+
assert.equal(typeof artifactId, 'string');
|
| 875 |
+
const content = await getArtifactContent(
|
| 876 |
+
new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`),
|
| 877 |
+
{ params: Promise.resolve({ id: artifactId }) }
|
| 878 |
+
);
|
| 879 |
+
assert.equal(content.status, 200);
|
| 880 |
+
assert.equal(
|
| 881 |
+
Buffer.compare(Buffer.from(await content.arrayBuffer()), Buffer.from(PNG_BASE64, 'base64')),
|
| 882 |
+
0
|
| 883 |
+
);
|
| 884 |
+
} finally {
|
| 885 |
+
await upstream.close();
|
| 886 |
+
}
|
| 887 |
+
});
|
| 888 |
+
|
| 889 |
+
it('fails Responses upstream SSE generate jobs when partial images arrive without a final image', async () => {
|
| 890 |
+
const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
|
| 891 |
+
const upstream = await startStreamingResponsesImageUpstream(() => [
|
| 892 |
+
{
|
| 893 |
+
event: 'response.image_generation_call.partial_image',
|
| 894 |
+
data: {
|
| 895 |
+
type: 'response.image_generation_call.partial_image',
|
| 896 |
+
partial_image_b64: 'job-responses-partial-only',
|
| 897 |
+
partial_image_index: 0
|
| 898 |
+
}
|
| 899 |
+
}
|
| 900 |
+
]);
|
| 901 |
+
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 902 |
+
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4';
|
| 903 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 904 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 905 |
+
|
| 906 |
+
try {
|
| 907 |
+
const created = await createGenerateJob(
|
| 908 |
+
agentJobJsonRequest('route-job-responses-missing-final-key', {
|
| 909 |
+
prompt: 'agent job responses partial only',
|
| 910 |
+
image_backend: 'responses-image-generation',
|
| 911 |
+
streaming_strategy: 'responses-sse',
|
| 912 |
+
partial_images: 2
|
| 913 |
+
})
|
| 914 |
+
);
|
| 915 |
+
assert.equal(created.status, 202);
|
| 916 |
+
const createdBody = await created.json();
|
| 917 |
+
|
| 918 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 919 |
+
assert.equal(result.status, 502);
|
| 920 |
+
const resultBody = await result.json();
|
| 921 |
+
assert.equal(resultBody.error.code, 'upstream_unavailable');
|
| 922 |
+
assert.match(resultBody.error.message, /最终图片 b64_json/);
|
| 923 |
+
assert.equal(resultBody.error.retryable, false);
|
| 924 |
+
assert.equal(
|
| 925 |
+
resultBody.error.diagnostics.upstream_event_type,
|
| 926 |
+
'response.image_generation_call.partial_image'
|
| 927 |
+
);
|
| 928 |
+
assert.equal(resultBody.error.diagnostics.partial_image_count, 1);
|
| 929 |
+
assert.equal(resultBody.error.diagnostics.retry_after_seconds, undefined);
|
| 930 |
+
assert.equal(JSON.stringify(resultBody).includes('job-responses-partial-only'), false);
|
| 931 |
+
|
| 932 |
+
const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
|
| 933 |
+
params: Promise.resolve({ id: createdBody.job.id })
|
| 934 |
+
});
|
| 935 |
+
assert.equal(status.status, 200);
|
| 936 |
+
const statusBody = await status.json();
|
| 937 |
+
assert.equal(statusBody.job.state, 'failed');
|
| 938 |
+
assert.equal(statusBody.job.error.code, 'upstream_unavailable');
|
| 939 |
+
assert.match(statusBody.job.error.message, /最终图片 b64_json/);
|
| 940 |
+
assert.equal(statusBody.job.error.retryable, false);
|
| 941 |
+
assert.equal(
|
| 942 |
+
statusBody.job.error.diagnostics.upstream_event_type,
|
| 943 |
+
'response.image_generation_call.partial_image'
|
| 944 |
+
);
|
| 945 |
+
assert.equal(statusBody.job.error.diagnostics.partial_image_count, 1);
|
| 946 |
+
assert.equal(statusBody.job.error.diagnostics.retry_after_seconds, undefined);
|
| 947 |
+
assert.equal(JSON.stringify(statusBody).includes('job-responses-partial-only'), false);
|
| 948 |
+
} finally {
|
| 949 |
+
await upstream.close();
|
| 950 |
+
}
|
| 951 |
+
});
|
| 952 |
+
|
| 953 |
it('reuses the running generate job for the same idempotency key', async () => {
|
| 954 |
const { createGenerateJob, getJobResult } = await loadAgentRoutes();
|
| 955 |
let releaseUpstream: (() => void) | undefined;
|
|
|
|
| 1002 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1003 |
|
| 1004 |
try {
|
| 1005 |
+
const first = await createGenerateJob(
|
| 1006 |
+
agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' })
|
| 1007 |
+
);
|
| 1008 |
assert.equal(first.status, 202);
|
| 1009 |
const firstBody = await first.json();
|
| 1010 |
await waitFor(() => upstreamCalls === 1);
|
| 1011 |
|
| 1012 |
+
const conflict = await createGenerateJob(
|
| 1013 |
+
agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' })
|
| 1014 |
+
);
|
| 1015 |
assert.equal(conflict.status, 409);
|
| 1016 |
assert.equal((await conflict.json()).error.code, 'idempotency_conflict');
|
| 1017 |
assert.equal(upstreamCalls, 1);
|
|
|
|
| 1036 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1037 |
|
| 1038 |
try {
|
| 1039 |
+
const created = await createGenerateJob(
|
| 1040 |
+
agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' })
|
| 1041 |
+
);
|
| 1042 |
assert.equal(created.status, 202);
|
| 1043 |
const createdBody = await created.json();
|
| 1044 |
|
|
|
|
| 1063 |
assert.equal(statusBody.job.error.upstream_status, 500);
|
| 1064 |
assert.equal(statusBody.job.error.diagnostics.upstream_status, 500);
|
| 1065 |
assert.equal(upstreamCalls > 0, true);
|
| 1066 |
+
|
| 1067 |
+
const replay = await createGenerateJob(
|
| 1068 |
+
agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' })
|
| 1069 |
+
);
|
| 1070 |
+
assert.equal(replay.status, 202);
|
| 1071 |
+
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
| 1072 |
+
const replayBody = await replay.json();
|
| 1073 |
+
assert.equal(replayBody.job.id, createdBody.job.id);
|
| 1074 |
+
assert.equal(replayBody.job.state, 'failed');
|
| 1075 |
} finally {
|
| 1076 |
await upstream.close();
|
| 1077 |
}
|
| 1078 |
});
|
| 1079 |
|
| 1080 |
+
it('marks generate jobs as failed when completion state persistence fails', async () => {
|
| 1081 |
+
const { createGenerateJob, getJob } = await loadAgentRoutes();
|
| 1082 |
+
const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime');
|
| 1083 |
+
const requestId = 'job-completion-failure-request';
|
| 1084 |
+
let failErrorCode: AgentErrorCode | undefined;
|
| 1085 |
+
let saveCalls = 0;
|
| 1086 |
+
const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
| 1087 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 1088 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1089 |
+
setAgentStateStoreFactoryForTests(() => ({
|
| 1090 |
+
async init() {},
|
| 1091 |
+
async recoverExpiredRequests() {
|
| 1092 |
+
return 0;
|
| 1093 |
+
},
|
| 1094 |
+
async purgeExpiredRequests() {
|
| 1095 |
+
return 0;
|
| 1096 |
+
},
|
| 1097 |
+
async beginRequest() {
|
| 1098 |
+
return {
|
| 1099 |
+
type: 'acquired',
|
| 1100 |
+
record: {
|
| 1101 |
+
requestId,
|
| 1102 |
+
idempotencyKey: 'job-completion-failure-key',
|
| 1103 |
+
requestHash: 'hash',
|
| 1104 |
+
mode: 'generate',
|
| 1105 |
+
status: 'running',
|
| 1106 |
+
requestJson: { prompt: 'job completion persistence failure' },
|
| 1107 |
+
createdAt: '2026-05-12T00:00:00.000Z',
|
| 1108 |
+
updatedAt: '2026-05-12T00:00:00.000Z',
|
| 1109 |
+
expiresAt: '2099-05-13T00:00:00.000Z'
|
| 1110 |
+
}
|
| 1111 |
+
};
|
| 1112 |
+
},
|
| 1113 |
+
async refreshRequestLease() {
|
| 1114 |
+
return true;
|
| 1115 |
+
},
|
| 1116 |
+
async saveArtifacts() {
|
| 1117 |
+
saveCalls += 1;
|
| 1118 |
+
},
|
| 1119 |
+
async completeRequest() {
|
| 1120 |
+
throw new Error('job completion persistence failed');
|
| 1121 |
+
},
|
| 1122 |
+
async failRequest(input: { requestId: string; error: { error: { code: AgentErrorCode } } }) {
|
| 1123 |
+
assert.equal(input.requestId, requestId);
|
| 1124 |
+
failErrorCode = input.error.error.code;
|
| 1125 |
+
},
|
| 1126 |
+
async getRequest(id: string) {
|
| 1127 |
+
if (id !== requestId || !failErrorCode) return undefined;
|
| 1128 |
+
return {
|
| 1129 |
+
requestId,
|
| 1130 |
+
idempotencyKey: 'job-completion-failure-key',
|
| 1131 |
+
requestHash: 'hash',
|
| 1132 |
+
mode: 'generate',
|
| 1133 |
+
status: 'failed',
|
| 1134 |
+
requestJson: { prompt: 'job completion persistence failure' },
|
| 1135 |
+
errorJson: {
|
| 1136 |
+
error: {
|
| 1137 |
+
code: failErrorCode,
|
| 1138 |
+
message: '保存请求完成状态失败。',
|
| 1139 |
+
retryable: true,
|
| 1140 |
+
request_id: requestId
|
| 1141 |
+
}
|
| 1142 |
+
},
|
| 1143 |
+
createdAt: '2026-05-12T00:00:00.000Z',
|
| 1144 |
+
updatedAt: '2026-05-12T00:00:01.000Z',
|
| 1145 |
+
expiresAt: '2099-05-13T00:00:00.000Z'
|
| 1146 |
+
};
|
| 1147 |
+
},
|
| 1148 |
+
async getArtifact() {
|
| 1149 |
+
return undefined;
|
| 1150 |
+
},
|
| 1151 |
+
async listArtifactsForRequest() {
|
| 1152 |
+
return [];
|
| 1153 |
+
},
|
| 1154 |
+
async deleteArtifact() {
|
| 1155 |
+
return false;
|
| 1156 |
+
}
|
| 1157 |
+
}));
|
| 1158 |
+
|
| 1159 |
+
const originalConsoleError = console.error;
|
| 1160 |
+
console.error = () => {};
|
| 1161 |
+
try {
|
| 1162 |
+
const created = await createGenerateJob(
|
| 1163 |
+
agentJobJsonRequest('job-completion-failure-key', { prompt: 'job completion persistence failure' })
|
| 1164 |
+
);
|
| 1165 |
+
assert.equal(created.status, 202);
|
| 1166 |
+
await waitFor(() => failErrorCode === 'unexpected_error');
|
| 1167 |
+
assert.equal(saveCalls, 1);
|
| 1168 |
+
|
| 1169 |
+
const status = await getJob(new Request(`http://localhost/api/agent/jobs/${requestId}`), {
|
| 1170 |
+
params: Promise.resolve({ id: requestId })
|
| 1171 |
+
});
|
| 1172 |
+
assert.equal(status.status, 200);
|
| 1173 |
+
const statusBody = await status.json();
|
| 1174 |
+
assert.equal(statusBody.job.state, 'failed');
|
| 1175 |
+
assert.equal(statusBody.job.error.code, 'unexpected_error');
|
| 1176 |
+
assert.equal(statusBody.job.error.retryable, false);
|
| 1177 |
+
} finally {
|
| 1178 |
+
console.error = originalConsoleError;
|
| 1179 |
+
await upstream.close();
|
| 1180 |
+
}
|
| 1181 |
+
});
|
| 1182 |
+
|
| 1183 |
it('keeps a long-running generate job leased while the upstream call is still active', async () => {
|
| 1184 |
process.env.AGENT_REQUEST_LEASE_MS = '200';
|
| 1185 |
process.env.AGENT_RECOVERY_INTERVAL_MS = '50';
|
|
|
|
| 1197 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1198 |
|
| 1199 |
try {
|
| 1200 |
+
const created = await createGenerateJob(
|
| 1201 |
+
agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' })
|
| 1202 |
+
);
|
| 1203 |
assert.equal(created.status, 202);
|
| 1204 |
const createdBody = await created.json();
|
| 1205 |
await waitFor(() => upstreamCalls === 1);
|
|
|
|
| 1223 |
|
| 1224 |
it('returns structured errors for missing and expired jobs', async () => {
|
| 1225 |
const { getJob, getJobResult } = await loadAgentRoutes();
|
| 1226 |
+
const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import(
|
| 1227 |
+
'@/lib/agent-state-runtime'
|
| 1228 |
+
);
|
| 1229 |
|
| 1230 |
const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), {
|
| 1231 |
params: Promise.resolve({ id: 'missing-job' })
|
|
|
|
| 1311 |
await upstream.close();
|
| 1312 |
});
|
| 1313 |
|
| 1314 |
+
it('aborts Agent edit upstream calls when the client request signal aborts', async () => {
|
| 1315 |
+
const { editImage } = await loadAgentRoutes();
|
| 1316 |
+
const upstream = await startHangingImageEditUpstream();
|
| 1317 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 1318 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1319 |
+
const abortController = new AbortController();
|
| 1320 |
+
|
| 1321 |
+
try {
|
| 1322 |
+
const responsePromise = editImage(
|
| 1323 |
+
agentEditRequest('route-edit-abort-key', 'agent edit abort', {}, 'path', {
|
| 1324 |
+
signal: abortController.signal
|
| 1325 |
+
})
|
| 1326 |
+
);
|
| 1327 |
+
await waitFor(() => upstream.requests === 1);
|
| 1328 |
+
abortController.abort();
|
| 1329 |
+
|
| 1330 |
+
const response = await Promise.race([
|
| 1331 |
+
responsePromise,
|
| 1332 |
+
new Promise<Response>((_, reject) =>
|
| 1333 |
+
setTimeout(() => reject(new Error('Agent edit upstream call did not abort')), 1500)
|
| 1334 |
+
)
|
| 1335 |
+
]);
|
| 1336 |
+
|
| 1337 |
+
assert.notEqual(response.status, 200);
|
| 1338 |
+
} finally {
|
| 1339 |
+
abortController.abort();
|
| 1340 |
+
await upstream.close();
|
| 1341 |
+
}
|
| 1342 |
+
});
|
| 1343 |
+
|
| 1344 |
it('returns field-level errors for invalid edit multipart requests', async () => {
|
| 1345 |
const { editImage } = await loadAgentRoutes();
|
| 1346 |
const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
| 1347 |
process.env.OPENAI_API_KEY = 'test-key';
|
| 1348 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1349 |
|
| 1350 |
+
const response = await editImage(
|
| 1351 |
+
agentEditRequest('route-edit-validation-key', 'agent edit invalid', {}, 'url')
|
| 1352 |
+
);
|
| 1353 |
|
| 1354 |
assert.equal(response.status, 422);
|
| 1355 |
const body = await response.json();
|
|
|
|
| 1359 |
await upstream.close();
|
| 1360 |
});
|
| 1361 |
|
| 1362 |
+
it('rejects high-resolution Agent edit requests before contacting upstream', async () => {
|
| 1363 |
+
const { editImage } = await loadAgentRoutes();
|
| 1364 |
+
let upstreamCalls = 0;
|
| 1365 |
+
const upstream = await startImageUpstream(() => {
|
| 1366 |
+
upstreamCalls += 1;
|
| 1367 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 1368 |
+
});
|
| 1369 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 1370 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1371 |
+
|
| 1372 |
+
try {
|
| 1373 |
+
const response = await editImage(
|
| 1374 |
+
agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' })
|
| 1375 |
+
);
|
| 1376 |
+
|
| 1377 |
+
assert.equal(response.status, 422);
|
| 1378 |
+
const body = await response.json();
|
| 1379 |
+
assert.equal(body.error.code, 'validation_error');
|
| 1380 |
+
assert.match(body.error.message, /\/api\/images/);
|
| 1381 |
+
assert.equal(upstreamCalls, 0);
|
| 1382 |
+
} finally {
|
| 1383 |
+
await upstream.close();
|
| 1384 |
+
}
|
| 1385 |
+
});
|
| 1386 |
+
|
| 1387 |
+
it('rejects high-resolution Agent edit requests before reading files or API credentials', async () => {
|
| 1388 |
+
const { editImage } = await loadAgentRoutes();
|
| 1389 |
+
delete process.env.OPENAI_API_KEY;
|
| 1390 |
+
delete process.env.OPENAI_API_BASE_URL;
|
| 1391 |
+
|
| 1392 |
+
const formData = new FormData();
|
| 1393 |
+
formData.append('prompt', 'high resolution edit without file');
|
| 1394 |
+
formData.append('model', 'gpt-image-2');
|
| 1395 |
+
formData.append('size', '3072x2048');
|
| 1396 |
+
formData.append('response_mode', 'path');
|
| 1397 |
+
|
| 1398 |
+
const response = await editImage(
|
| 1399 |
+
new Request('http://localhost/api/agent/images/edit', {
|
| 1400 |
+
method: 'POST',
|
| 1401 |
+
headers: {
|
| 1402 |
+
'Idempotency-Key': 'route-edit-high-resolution-no-file-key'
|
| 1403 |
+
},
|
| 1404 |
+
body: formData
|
| 1405 |
+
})
|
| 1406 |
+
);
|
| 1407 |
+
|
| 1408 |
+
assert.equal(response.status, 422);
|
| 1409 |
+
const body = await response.json();
|
| 1410 |
+
assert.equal(body.error.code, 'validation_error');
|
| 1411 |
+
assert.match(body.error.message, /\/api\/images/);
|
| 1412 |
+
assert.equal(body.error.details?.fields?.image_0, undefined);
|
| 1413 |
+
assert.doesNotMatch(body.error.message, /API Key|图片文件/);
|
| 1414 |
+
});
|
| 1415 |
+
|
| 1416 |
+
it('rejects auto-size Agent edit when the uploaded source image is high resolution', async () => {
|
| 1417 |
+
const { editImage } = await loadAgentRoutes();
|
| 1418 |
+
let upstreamCalls = 0;
|
| 1419 |
+
const upstream = await startImageUpstream(() => {
|
| 1420 |
+
upstreamCalls += 1;
|
| 1421 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 1422 |
+
});
|
| 1423 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 1424 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1425 |
+
|
| 1426 |
+
try {
|
| 1427 |
+
const response = await editImage(
|
| 1428 |
+
agentEditRequest(
|
| 1429 |
+
'route-edit-auto-high-resolution-source-key',
|
| 1430 |
+
'auto high resolution source',
|
| 1431 |
+
{},
|
| 1432 |
+
{
|
| 1433 |
+
size: 'auto',
|
| 1434 |
+
image_0: createPngWithDimensions(3072, 2048)
|
| 1435 |
+
}
|
| 1436 |
+
)
|
| 1437 |
+
);
|
| 1438 |
+
|
| 1439 |
+
assert.equal(response.status, 422);
|
| 1440 |
+
const body = await response.json();
|
| 1441 |
+
assert.equal(body.error.code, 'validation_error');
|
| 1442 |
+
assert.match(body.error.message, /\/api\/images/);
|
| 1443 |
+
assert.equal(upstreamCalls, 0);
|
| 1444 |
+
} finally {
|
| 1445 |
+
await upstream.close();
|
| 1446 |
+
}
|
| 1447 |
+
});
|
| 1448 |
+
|
| 1449 |
it('returns validation errors for non-multipart edit requests', async () => {
|
| 1450 |
const { editImage } = await loadAgentRoutes();
|
| 1451 |
|
|
|
|
| 1514 |
const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics'));
|
| 1515 |
assert.equal(replay.status, 502);
|
| 1516 |
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
| 1517 |
+
assert.equal(replay.headers.get('x-request-id'), body.error.request_id);
|
| 1518 |
const replayBody = await replay.json();
|
| 1519 |
assert.equal(replayBody.error.code, 'upstream_unavailable');
|
| 1520 |
assert.equal(replayBody.error.retryable, false);
|
|
|
|
| 1591 |
const originalConsoleError = console.error;
|
| 1592 |
console.error = () => {};
|
| 1593 |
try {
|
| 1594 |
+
const response = await editImage(
|
| 1595 |
+
agentEditRequest('edit-completion-failure-key', 'state completion failure')
|
| 1596 |
+
);
|
| 1597 |
|
| 1598 |
assert.equal(response.status, 500);
|
| 1599 |
const body = await response.json();
|
|
|
|
| 1617 |
process.env.AGENT_API_TOKEN = 'artifact-token';
|
| 1618 |
|
| 1619 |
const generated = await generateImage(
|
| 1620 |
+
agentJsonRequest(
|
| 1621 |
+
'artifact-auth-key',
|
| 1622 |
+
{ prompt: 'artifact auth' },
|
| 1623 |
+
{ Authorization: 'Bearer artifact-token' }
|
| 1624 |
+
)
|
| 1625 |
);
|
| 1626 |
const body = await generated.json();
|
| 1627 |
const artifactId = body.images[0].id;
|
| 1628 |
|
| 1629 |
+
const denied = await getArtifactContent(
|
| 1630 |
+
new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`),
|
| 1631 |
+
{
|
| 1632 |
+
params: Promise.resolve({ id: artifactId })
|
| 1633 |
+
}
|
| 1634 |
+
);
|
| 1635 |
assert.equal(denied.status, 401);
|
| 1636 |
assert.equal((await denied.json()).error.code, 'unauthorized');
|
| 1637 |
|
|
|
|
| 1667 |
assert.equal((await deleted.json()).deleted, true);
|
| 1668 |
|
| 1669 |
const replayAfterDelete = await generateImage(
|
| 1670 |
+
agentJsonRequest(
|
| 1671 |
+
'artifact-auth-key',
|
| 1672 |
+
{ prompt: 'artifact auth' },
|
| 1673 |
+
{ Authorization: 'Bearer artifact-token' }
|
| 1674 |
+
)
|
| 1675 |
);
|
| 1676 |
assert.equal(replayAfterDelete.status, 404);
|
| 1677 |
assert.equal((await replayAfterDelete.json()).error.code, 'artifact_not_found');
|
|
|
|
| 1686 |
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1687 |
|
| 1688 |
try {
|
| 1689 |
+
const generated = await generateImage(
|
| 1690 |
+
agentJsonRequest('artifact-missing-content-key', { prompt: 'missing content' })
|
| 1691 |
+
);
|
| 1692 |
const body = await generated.json();
|
| 1693 |
const image = body.images[0];
|
| 1694 |
await rm(readStoredArtifactFilepath(image.id), { force: true });
|
|
|
|
| 1771 |
const originalConsoleError = console.error;
|
| 1772 |
console.error = () => {};
|
| 1773 |
try {
|
| 1774 |
+
const response = await generateImage(
|
| 1775 |
+
agentJsonRequest('completion-failure-key', { prompt: 'state completion failure' })
|
| 1776 |
+
);
|
| 1777 |
|
| 1778 |
assert.equal(response.status, 500);
|
| 1779 |
const body = await response.json();
|
|
|
|
| 1849 |
const originalConsoleError = console.error;
|
| 1850 |
console.error = () => {};
|
| 1851 |
try {
|
| 1852 |
+
const response = await generateImage(
|
| 1853 |
+
agentJsonRequest('artifact-save-failure-key', { prompt: 'artifact save failure' })
|
| 1854 |
+
);
|
| 1855 |
|
| 1856 |
assert.equal(response.status, 500);
|
| 1857 |
const body = await response.json();
|
|
|
|
| 1868 |
|
| 1869 |
const livePostgresUrl = process.env.AGENT_POSTGRES_TEST_DATABASE_URL;
|
| 1870 |
|
| 1871 |
+
describe(
|
| 1872 |
+
'Agent route PostgreSQL integration',
|
| 1873 |
+
{ skip: livePostgresUrl ? false : 'AGENT_POSTGRES_TEST_DATABASE_URL is not set' },
|
| 1874 |
+
() => {
|
| 1875 |
+
it('allows only one upstream winner for concurrent identical idempotency requests', async () => {
|
| 1876 |
+
assert.ok(livePostgresUrl);
|
| 1877 |
+
const { generateImage } = await loadAgentRoutes();
|
| 1878 |
+
const schemaName = `agent_route_${Date.now().toString(36)}`;
|
| 1879 |
+
const pool = new Pool({ connectionString: livePostgresUrl });
|
| 1880 |
+
process.env.AGENT_STATE_BACKEND = 'postgres';
|
| 1881 |
+
process.env.AGENT_DATABASE_URL = `${livePostgresUrl}${livePostgresUrl.includes('?') ? '&' : '?'}options=-c%20search_path%3D${schemaName}`;
|
| 1882 |
+
process.env.AGENT_REQUEST_LEASE_MS = '60000';
|
| 1883 |
+
const admin = await pool.connect();
|
| 1884 |
+
let releaseUpstream: (() => void) | undefined;
|
| 1885 |
+
let upstreamCalls = 0;
|
| 1886 |
+
const upstream = await startImageUpstream(async () => {
|
| 1887 |
+
upstreamCalls += 1;
|
| 1888 |
+
await new Promise<void>((resolve) => {
|
| 1889 |
+
releaseUpstream = resolve;
|
| 1890 |
+
});
|
| 1891 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 1892 |
});
|
| 1893 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 1894 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 1895 |
+
|
| 1896 |
+
try {
|
| 1897 |
+
await admin.query(`CREATE SCHEMA "${schemaName}"`);
|
| 1898 |
+
const firstRequest = generateImage(
|
| 1899 |
+
agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' })
|
| 1900 |
+
);
|
| 1901 |
+
await waitFor(() => upstreamCalls === 1);
|
| 1902 |
+
|
| 1903 |
+
const second = await generateImage(
|
| 1904 |
+
agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' })
|
| 1905 |
+
);
|
| 1906 |
+
assert.equal(second.status, 409);
|
| 1907 |
+
const secondBody = await second.json();
|
| 1908 |
+
assert.equal(secondBody.error.code, 'request_in_progress');
|
| 1909 |
+
assert.equal(secondBody.error.retryable, true);
|
| 1910 |
+
assert.equal(second.headers.has('retry-after'), true);
|
| 1911 |
+
|
| 1912 |
+
releaseUpstream?.();
|
| 1913 |
+
const first = await firstRequest;
|
| 1914 |
+
assert.equal(first.status, 200);
|
| 1915 |
+
|
| 1916 |
+
const replay = await generateImage(
|
| 1917 |
+
agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' })
|
| 1918 |
+
);
|
| 1919 |
+
assert.equal(replay.status, 200);
|
| 1920 |
+
assert.equal((await replay.json()).cached, true);
|
| 1921 |
+
assert.equal(upstreamCalls, 1);
|
| 1922 |
+
} finally {
|
| 1923 |
+
releaseUpstream?.();
|
| 1924 |
+
await upstream.close();
|
| 1925 |
+
await admin.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
| 1926 |
+
admin.release();
|
| 1927 |
+
await pool.end();
|
| 1928 |
+
}
|
| 1929 |
});
|
| 1930 |
+
}
|
| 1931 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1932 |
|
| 1933 |
async function loadAgentRoutes() {
|
| 1934 |
const { resetAgentStateStoreForTests } = await import('@/lib/agent-state-runtime');
|
|
|
|
| 1944 |
const jobRoute = await import('./jobs/[id]/route');
|
| 1945 |
const jobResultRoute = await import('./jobs/[id]/result/route');
|
| 1946 |
return {
|
| 1947 |
+
getCapabilities: () => capabilitiesRoute.GET(),
|
| 1948 |
+
generateImage: (request: Request) => generateRoute.POST(asNextRequest(request)),
|
| 1949 |
+
editImage: (request: Request) => editRoute.POST(asNextRequest(request)),
|
| 1950 |
+
createGenerateJob: (request: Request) => createGenerateJobRoute.POST(asNextRequest(request)),
|
| 1951 |
+
getJob: (request: Request, context: AgentRouteContext) => jobRoute.GET(asNextRequest(request), context),
|
| 1952 |
+
getJobResult: (request: Request, context: AgentRouteContext) =>
|
| 1953 |
+
jobResultRoute.GET(asNextRequest(request), context),
|
| 1954 |
+
getArtifact: (request: Request, context: AgentRouteContext) =>
|
| 1955 |
+
artifactRoute.GET(asNextRequest(request), context),
|
| 1956 |
+
deleteArtifact: (request: Request, context: AgentRouteContext) =>
|
| 1957 |
+
artifactRoute.DELETE(asNextRequest(request), context),
|
| 1958 |
+
getArtifactContent: (request: Request, context: AgentRouteContext) =>
|
| 1959 |
+
artifactContentRoute.GET(asNextRequest(request), context)
|
| 1960 |
};
|
| 1961 |
}
|
| 1962 |
|
| 1963 |
+
type AgentRouteContext = { params: Promise<{ id: string }> };
|
| 1964 |
+
|
| 1965 |
+
function asNextRequest(request: Request): NextRequest {
|
| 1966 |
+
return request as unknown as NextRequest;
|
| 1967 |
+
}
|
| 1968 |
+
|
| 1969 |
function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
|
| 1970 |
return new Request('http://localhost/api/agent/images/generate', {
|
| 1971 |
method: 'POST',
|
|
|
|
| 1978 |
});
|
| 1979 |
}
|
| 1980 |
|
| 1981 |
+
function agentJobJsonRequest(
|
| 1982 |
+
idempotencyKey: string,
|
| 1983 |
+
body: Record<string, unknown>,
|
| 1984 |
+
headers: Record<string, string> = {}
|
| 1985 |
+
) {
|
| 1986 |
return new Request('http://localhost/api/agent/jobs/images/generate', {
|
| 1987 |
method: 'POST',
|
| 1988 |
headers: {
|
|
|
|
| 1994 |
});
|
| 1995 |
}
|
| 1996 |
|
| 1997 |
+
type AgentEditRequestFields = {
|
| 1998 |
+
image_0?: Buffer;
|
| 1999 |
+
[field: string]: string | Buffer | undefined;
|
| 2000 |
+
};
|
| 2001 |
+
|
| 2002 |
function agentEditRequest(
|
| 2003 |
idempotencyKey: string,
|
| 2004 |
prompt: string,
|
| 2005 |
headers: Record<string, string> = {},
|
| 2006 |
+
responseModeOrFields: string | AgentEditRequestFields = 'path',
|
| 2007 |
+
options: { signal?: AbortSignal } = {}
|
| 2008 |
) {
|
| 2009 |
+
const fields: AgentEditRequestFields =
|
| 2010 |
+
typeof responseModeOrFields === 'string'
|
| 2011 |
+
? { response_mode: responseModeOrFields }
|
| 2012 |
+
: { response_mode: 'path', ...responseModeOrFields };
|
| 2013 |
+
const imageBuffer = fields.image_0 ?? Buffer.from(PNG_BASE64, 'base64');
|
| 2014 |
const formData = new FormData();
|
| 2015 |
formData.append('prompt', prompt);
|
| 2016 |
formData.append('model', 'gpt-image-2');
|
| 2017 |
+
for (const [key, value] of Object.entries(fields)) {
|
| 2018 |
+
if (key === 'image_0') continue;
|
| 2019 |
+
if (typeof value !== 'string') {
|
| 2020 |
+
throw new TypeError(`Agent edit test field ${key} must be a string.`);
|
| 2021 |
+
}
|
| 2022 |
+
formData.append(key, value);
|
| 2023 |
+
}
|
| 2024 |
+
formData.append('image_0', new File([imageBuffer], 'input.png', { type: 'image/png' }));
|
| 2025 |
return new Request('http://localhost/api/agent/images/edit', {
|
| 2026 |
method: 'POST',
|
| 2027 |
headers: {
|
| 2028 |
'Idempotency-Key': idempotencyKey,
|
| 2029 |
...headers
|
| 2030 |
},
|
| 2031 |
+
body: formData,
|
| 2032 |
+
signal: options.signal
|
| 2033 |
});
|
| 2034 |
}
|
| 2035 |
|
| 2036 |
+
function createPngWithDimensions(width: number, height: number): Buffer {
|
| 2037 |
+
const buffer = Buffer.from(PNG_BASE64, 'base64');
|
| 2038 |
+
buffer.writeUInt32BE(width, 16);
|
| 2039 |
+
buffer.writeUInt32BE(height, 20);
|
| 2040 |
+
return buffer;
|
| 2041 |
+
}
|
| 2042 |
+
|
| 2043 |
+
async function startImageUpstream(
|
| 2044 |
+
handler: (body: string, url: string) => unknown | Promise<unknown>
|
| 2045 |
+
): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
| 2046 |
const server = http.createServer(async (request, response) => {
|
| 2047 |
if (
|
| 2048 |
request.method !== 'POST' ||
|
|
|
|
| 2052 |
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 2053 |
return;
|
| 2054 |
}
|
| 2055 |
+
const chunks: Buffer[] = [];
|
| 2056 |
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
| 2057 |
+
await new Promise<void>((resolve) => request.on('end', resolve));
|
| 2058 |
try {
|
| 2059 |
+
const body = await handler(Buffer.concat(chunks).toString('utf8'), request.url || '');
|
| 2060 |
response.writeHead(200, { 'Content-Type': 'application/json' });
|
| 2061 |
response.end(JSON.stringify(body));
|
| 2062 |
} catch (error) {
|
|
|
|
| 2074 |
};
|
| 2075 |
}
|
| 2076 |
|
| 2077 |
+
async function startHangingImageEditUpstream(): Promise<{
|
| 2078 |
+
baseUrl: string;
|
| 2079 |
+
readonly requests: number;
|
| 2080 |
+
close: () => Promise<void>;
|
| 2081 |
+
}> {
|
| 2082 |
+
let requests = 0;
|
| 2083 |
+
const sockets = new Set<Socket>();
|
| 2084 |
+
const server = http.createServer(async (request, response) => {
|
| 2085 |
+
if (request.method === 'POST' && request.url?.endsWith('/images/edits')) {
|
| 2086 |
+
requests += 1;
|
| 2087 |
+
request.resume();
|
| 2088 |
+
return;
|
| 2089 |
+
}
|
| 2090 |
+
request.resume();
|
| 2091 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 2092 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 2093 |
+
});
|
| 2094 |
+
server.on('connection', (socket) => {
|
| 2095 |
+
sockets.add(socket);
|
| 2096 |
+
socket.on('close', () => sockets.delete(socket));
|
| 2097 |
+
});
|
| 2098 |
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 2099 |
+
const address = server.address();
|
| 2100 |
+
assert.ok(address && typeof address === 'object');
|
| 2101 |
+
return {
|
| 2102 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 2103 |
+
get requests() {
|
| 2104 |
+
return requests;
|
| 2105 |
+
},
|
| 2106 |
+
close: () =>
|
| 2107 |
+
new Promise((resolve, reject) => {
|
| 2108 |
+
for (const socket of sockets) socket.destroy();
|
| 2109 |
+
server.close((error) => (error ? reject(error) : resolve()));
|
| 2110 |
+
})
|
| 2111 |
+
};
|
| 2112 |
+
}
|
| 2113 |
+
|
| 2114 |
+
async function startStreamingImageUpstream(
|
| 2115 |
+
handler: (
|
| 2116 |
+
body: string
|
| 2117 |
+
) => Array<{ event?: string; data: unknown }> | Promise<Array<{ event?: string; data: unknown }>>
|
| 2118 |
+
): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
| 2119 |
+
const server = http.createServer(async (request, response) => {
|
| 2120 |
+
if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) {
|
| 2121 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 2122 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 2123 |
+
return;
|
| 2124 |
+
}
|
| 2125 |
+
const chunks: Buffer[] = [];
|
| 2126 |
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
| 2127 |
+
await new Promise<void>((resolve) => request.on('end', resolve));
|
| 2128 |
+
const events = await handler(Buffer.concat(chunks).toString('utf8'));
|
| 2129 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 2130 |
+
for (const event of events) {
|
| 2131 |
+
if (event.event) {
|
| 2132 |
+
response.write(`event: ${event.event}\n`);
|
| 2133 |
+
}
|
| 2134 |
+
response.write(`data: ${JSON.stringify(event.data)}\n\n`);
|
| 2135 |
+
}
|
| 2136 |
+
response.write('data: [DONE]\n\n');
|
| 2137 |
+
response.end();
|
| 2138 |
+
});
|
| 2139 |
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 2140 |
+
const address = server.address();
|
| 2141 |
+
assert.ok(address && typeof address === 'object');
|
| 2142 |
+
return {
|
| 2143 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 2144 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 2145 |
+
};
|
| 2146 |
+
}
|
| 2147 |
+
|
| 2148 |
+
async function startStreamingResponsesImageUpstream(
|
| 2149 |
+
handler: (
|
| 2150 |
+
body: string
|
| 2151 |
+
) => Array<{ event?: string; data: unknown }> | Promise<Array<{ event?: string; data: unknown }>>
|
| 2152 |
+
): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
| 2153 |
+
const server = http.createServer(async (request, response) => {
|
| 2154 |
+
if (request.method !== 'POST' || !request.url?.endsWith('/responses')) {
|
| 2155 |
+
response.writeHead(404, { 'Content-Type': 'application/json' });
|
| 2156 |
+
response.end(JSON.stringify({ error: { message: 'not found' } }));
|
| 2157 |
+
return;
|
| 2158 |
+
}
|
| 2159 |
+
const chunks: Buffer[] = [];
|
| 2160 |
+
request.on('data', (chunk: Buffer) => chunks.push(chunk));
|
| 2161 |
+
await new Promise<void>((resolve) => request.on('end', resolve));
|
| 2162 |
+
const events = await handler(Buffer.concat(chunks).toString('utf8'));
|
| 2163 |
+
response.writeHead(200, { 'Content-Type': 'text/event-stream' });
|
| 2164 |
+
for (const event of events) {
|
| 2165 |
+
if (event.event) {
|
| 2166 |
+
response.write(`event: ${event.event}\n`);
|
| 2167 |
+
}
|
| 2168 |
+
response.write(`data: ${JSON.stringify(event.data)}\n\n`);
|
| 2169 |
+
}
|
| 2170 |
+
response.write('data: [DONE]\n\n');
|
| 2171 |
+
response.end();
|
| 2172 |
+
});
|
| 2173 |
+
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 2174 |
+
const address = server.address();
|
| 2175 |
+
assert.ok(address && typeof address === 'object');
|
| 2176 |
+
return {
|
| 2177 |
+
baseUrl: `http://127.0.0.1:${address.port}/v1`,
|
| 2178 |
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 2179 |
+
};
|
| 2180 |
+
}
|
| 2181 |
+
|
| 2182 |
function readStoredResponseJson(idempotencyKey: string): string {
|
| 2183 |
const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true });
|
| 2184 |
try {
|
|
|
|
| 2195 |
function readStoredArtifactFilepath(id: string): string {
|
| 2196 |
const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true });
|
| 2197 |
try {
|
| 2198 |
+
const row = db.prepare('SELECT filepath FROM agent_artifacts WHERE id = ?').get(id) as
|
| 2199 |
+
| { filepath: string }
|
| 2200 |
+
| undefined;
|
| 2201 |
assert.ok(row);
|
| 2202 |
return row.filepath;
|
| 2203 |
} finally {
|
|
|
|
| 2207 |
|
| 2208 |
async function listGeneratedImageFiles(): Promise<string[]> {
|
| 2209 |
try {
|
| 2210 |
+
return (await readdir(path.join(tempDir, 'generated-images'))).filter((entry) =>
|
| 2211 |
+
/\.(png|jpe?g|webp)$/i.test(entry)
|
| 2212 |
+
);
|
| 2213 |
} catch {
|
| 2214 |
return [];
|
| 2215 |
}
|
|
|
|
| 2232 |
}
|
| 2233 |
|
| 2234 |
async function waitForJobResult(
|
| 2235 |
+
getJobResult: (request: Request, context: { params: Promise<{ id: string }> }) => Promise<Response>,
|
|
|
|
|
|
|
|
|
|
| 2236 |
id: string
|
| 2237 |
): Promise<Response> {
|
| 2238 |
for (let attempt = 0; attempt < 200; attempt += 1) {
|
src/app/api/agent/capabilities/route.ts
CHANGED
|
@@ -16,6 +16,8 @@ function readPublicCapabilitiesEnv(): Record<string, string | undefined> {
|
|
| 16 |
AGENT_DB_PASSWORD_FILE: process.env.AGENT_DB_PASSWORD_FILE ? 'configured' : undefined,
|
| 17 |
AGENT_REQUEST_TTL_SECONDS: process.env.AGENT_REQUEST_TTL_SECONDS,
|
| 18 |
AGENT_PUBLIC_BASE_URL: process.env.AGENT_PUBLIC_BASE_URL,
|
|
|
|
|
|
|
| 19 |
NEXT_PUBLIC_IMAGE_STORAGE_MODE: process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE,
|
| 20 |
VERCEL: process.env.VERCEL
|
| 21 |
};
|
|
|
|
| 16 |
AGENT_DB_PASSWORD_FILE: process.env.AGENT_DB_PASSWORD_FILE ? 'configured' : undefined,
|
| 17 |
AGENT_REQUEST_TTL_SECONDS: process.env.AGENT_REQUEST_TTL_SECONDS,
|
| 18 |
AGENT_PUBLIC_BASE_URL: process.env.AGENT_PUBLIC_BASE_URL,
|
| 19 |
+
ENABLE_RESPONSES_IMAGE_BACKEND: process.env.ENABLE_RESPONSES_IMAGE_BACKEND,
|
| 20 |
+
OPENAI_RESPONSES_API_MODEL: process.env.OPENAI_RESPONSES_API_MODEL?.trim() ? 'configured' : undefined,
|
| 21 |
NEXT_PUBLIC_IMAGE_STORAGE_MODE: process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE,
|
| 22 |
VERCEL: process.env.VERCEL
|
| 23 |
};
|
src/app/api/agent/images/edit/route.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
|
|
|
|
|
| 1 |
import {
|
|
|
|
| 2 |
buildEditRequestHashFromSnapshot,
|
| 3 |
completeAgentExecutionState,
|
| 4 |
createArtifactPersistenceError,
|
|
@@ -12,11 +15,14 @@ import {
|
|
| 12 |
saveAgentExecutionArtifacts,
|
| 13 |
snapshotAgentEditFormData
|
| 14 |
} from '@/lib/agent-image-service';
|
| 15 |
-
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
|
| 16 |
-
import { AgentApiError, agentErrorResponse, normalizeAgentError, storedAgentErrorResponse } from '@/lib/api-error-response';
|
| 17 |
-
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 18 |
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 19 |
import { createRequestId } from '@/lib/agent-state-store';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
import { appLogger } from '@/lib/app-logger';
|
| 21 |
import { NextRequest, NextResponse } from 'next/server';
|
| 22 |
|
|
@@ -25,6 +31,7 @@ export async function POST(request: NextRequest) {
|
|
| 25 |
try {
|
| 26 |
assertAgentAuthorized(request.headers);
|
| 27 |
const formData = await parseAgentEditFormData(request);
|
|
|
|
| 28 |
const idempotencyKey = readIdempotencyKey(request.headers);
|
| 29 |
const requestSnapshot = await snapshotAgentEditFormData(formData);
|
| 30 |
const store = await ensureAgentStateStoreReady();
|
|
@@ -45,7 +52,10 @@ export async function POST(request: NextRequest) {
|
|
| 45 |
}
|
| 46 |
if (beginResult.type === 'failed') {
|
| 47 |
requestId = beginResult.record.requestId;
|
| 48 |
-
return storedAgentErrorResponse(beginResult.error, {
|
|
|
|
|
|
|
|
|
|
| 49 |
}
|
| 50 |
if (beginResult.type === 'conflict') {
|
| 51 |
throw new AgentApiError({
|
|
@@ -71,7 +81,8 @@ export async function POST(request: NextRequest) {
|
|
| 71 |
headers: request.headers,
|
| 72 |
requestId,
|
| 73 |
idempotencyKey,
|
| 74 |
-
cached: false
|
|
|
|
| 75 |
}).catch(async (error) => {
|
| 76 |
const errorBody = errorToAgentErrorBody(error, requestId);
|
| 77 |
await store.failRequest({ requestId, error: errorBody });
|
|
|
|
| 1 |
+
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
|
| 2 |
+
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 3 |
import {
|
| 4 |
+
assertAgentEditRouteAllowedFromFormData,
|
| 5 |
buildEditRequestHashFromSnapshot,
|
| 6 |
completeAgentExecutionState,
|
| 7 |
createArtifactPersistenceError,
|
|
|
|
| 15 |
saveAgentExecutionArtifacts,
|
| 16 |
snapshotAgentEditFormData
|
| 17 |
} from '@/lib/agent-image-service';
|
|
|
|
|
|
|
|
|
|
| 18 |
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 19 |
import { createRequestId } from '@/lib/agent-state-store';
|
| 20 |
+
import {
|
| 21 |
+
AgentApiError,
|
| 22 |
+
agentErrorResponse,
|
| 23 |
+
normalizeAgentError,
|
| 24 |
+
storedAgentErrorResponse
|
| 25 |
+
} from '@/lib/api-error-response';
|
| 26 |
import { appLogger } from '@/lib/app-logger';
|
| 27 |
import { NextRequest, NextResponse } from 'next/server';
|
| 28 |
|
|
|
|
| 31 |
try {
|
| 32 |
assertAgentAuthorized(request.headers);
|
| 33 |
const formData = await parseAgentEditFormData(request);
|
| 34 |
+
await assertAgentEditRouteAllowedFromFormData(formData);
|
| 35 |
const idempotencyKey = readIdempotencyKey(request.headers);
|
| 36 |
const requestSnapshot = await snapshotAgentEditFormData(formData);
|
| 37 |
const store = await ensureAgentStateStoreReady();
|
|
|
|
| 52 |
}
|
| 53 |
if (beginResult.type === 'failed') {
|
| 54 |
requestId = beginResult.record.requestId;
|
| 55 |
+
return storedAgentErrorResponse(beginResult.error, {
|
| 56 |
+
'X-Idempotent-Replay': 'true',
|
| 57 |
+
'X-Request-Id': requestId
|
| 58 |
+
});
|
| 59 |
}
|
| 60 |
if (beginResult.type === 'conflict') {
|
| 61 |
throw new AgentApiError({
|
|
|
|
| 81 |
headers: request.headers,
|
| 82 |
requestId,
|
| 83 |
idempotencyKey,
|
| 84 |
+
cached: false,
|
| 85 |
+
abortSignal: request.signal
|
| 86 |
}).catch(async (error) => {
|
| 87 |
const errorBody = errorToAgentErrorBody(error, requestId);
|
| 88 |
await store.failRequest({ requestId, error: errorBody });
|
src/app/api/agent/images/generate/route.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
import {
|
| 2 |
buildGenerateRequestHash,
|
| 3 |
completeAgentExecutionState,
|
|
@@ -11,11 +13,14 @@ import {
|
|
| 11 |
readIdempotencyKey,
|
| 12 |
saveAgentExecutionArtifacts
|
| 13 |
} from '@/lib/agent-image-service';
|
| 14 |
-
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
|
| 15 |
-
import { AgentApiError, agentErrorResponse, normalizeAgentError, storedAgentErrorResponse } from '@/lib/api-error-response';
|
| 16 |
-
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 17 |
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 18 |
import { createRequestId } from '@/lib/agent-state-store';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
import { appLogger } from '@/lib/app-logger';
|
| 20 |
import { NextRequest, NextResponse } from 'next/server';
|
| 21 |
|
|
@@ -43,7 +48,10 @@ export async function POST(request: NextRequest) {
|
|
| 43 |
}
|
| 44 |
if (beginResult.type === 'failed') {
|
| 45 |
requestId = beginResult.record.requestId;
|
| 46 |
-
return storedAgentErrorResponse(beginResult.error, {
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
if (beginResult.type === 'conflict') {
|
| 49 |
throw new AgentApiError({
|
|
@@ -69,7 +77,8 @@ export async function POST(request: NextRequest) {
|
|
| 69 |
headers: request.headers,
|
| 70 |
requestId,
|
| 71 |
idempotencyKey,
|
| 72 |
-
cached: false
|
|
|
|
| 73 |
}).catch(async (error) => {
|
| 74 |
const errorBody = errorToAgentErrorBody(error, requestId);
|
| 75 |
await store.failRequest({ requestId, error: errorBody });
|
|
|
|
| 1 |
+
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
|
| 2 |
+
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 3 |
import {
|
| 4 |
buildGenerateRequestHash,
|
| 5 |
completeAgentExecutionState,
|
|
|
|
| 13 |
readIdempotencyKey,
|
| 14 |
saveAgentExecutionArtifacts
|
| 15 |
} from '@/lib/agent-image-service';
|
|
|
|
|
|
|
|
|
|
| 16 |
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 17 |
import { createRequestId } from '@/lib/agent-state-store';
|
| 18 |
+
import {
|
| 19 |
+
AgentApiError,
|
| 20 |
+
agentErrorResponse,
|
| 21 |
+
normalizeAgentError,
|
| 22 |
+
storedAgentErrorResponse
|
| 23 |
+
} from '@/lib/api-error-response';
|
| 24 |
import { appLogger } from '@/lib/app-logger';
|
| 25 |
import { NextRequest, NextResponse } from 'next/server';
|
| 26 |
|
|
|
|
| 48 |
}
|
| 49 |
if (beginResult.type === 'failed') {
|
| 50 |
requestId = beginResult.record.requestId;
|
| 51 |
+
return storedAgentErrorResponse(beginResult.error, {
|
| 52 |
+
'X-Idempotent-Replay': 'true',
|
| 53 |
+
'X-Request-Id': requestId
|
| 54 |
+
});
|
| 55 |
}
|
| 56 |
if (beginResult.type === 'conflict') {
|
| 57 |
throw new AgentApiError({
|
|
|
|
| 77 |
headers: request.headers,
|
| 78 |
requestId,
|
| 79 |
idempotencyKey,
|
| 80 |
+
cached: false,
|
| 81 |
+
abortSignal: request.signal
|
| 82 |
}).catch(async (error) => {
|
| 83 |
const errorBody = errorToAgentErrorBody(error, requestId);
|
| 84 |
await store.failRequest({ requestId, error: errorBody });
|
src/app/api/agent/jobs/[id]/result/route.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
import {
|
| 2 |
assertReadableJobRecord,
|
| 3 |
readAgentJobState,
|
| 4 |
-
|
| 5 |
} from '@/lib/agent-job-service';
|
| 6 |
import {
|
| 7 |
AgentApiError,
|
|
@@ -26,7 +26,7 @@ export async function GET(request: NextRequest, context: RouteContext) {
|
|
| 26 |
const store = await ensureAgentStateStoreReady();
|
| 27 |
const record = assertReadableJobRecord(await store.getRequest(id), id);
|
| 28 |
requestId = record.requestId;
|
| 29 |
-
const result = await
|
| 30 |
if (result.type === 'response') {
|
| 31 |
return NextResponse.json(result.response, { headers: { 'X-Request-Id': requestId } });
|
| 32 |
}
|
|
|
|
| 1 |
import {
|
| 2 |
assertReadableJobRecord,
|
| 3 |
readAgentJobState,
|
| 4 |
+
readJobResult
|
| 5 |
} from '@/lib/agent-job-service';
|
| 6 |
import {
|
| 7 |
AgentApiError,
|
|
|
|
| 26 |
const store = await ensureAgentStateStoreReady();
|
| 27 |
const record = assertReadableJobRecord(await store.getRequest(id), id);
|
| 28 |
requestId = record.requestId;
|
| 29 |
+
const result = await readJobResult(store, record);
|
| 30 |
if (result.type === 'response') {
|
| 31 |
return NextResponse.json(result.response, { headers: { 'X-Request-Id': requestId } });
|
| 32 |
}
|
src/app/api/agent/jobs/images/generate/route.ts
CHANGED
|
@@ -34,6 +34,7 @@ export async function POST(request: NextRequest) {
|
|
| 34 |
if (beginResult.type === 'replay' || beginResult.type === 'failed') {
|
| 35 |
requestId = beginResult.record.requestId;
|
| 36 |
return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), {
|
|
|
|
| 37 |
headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': requestId }
|
| 38 |
});
|
| 39 |
}
|
|
|
|
| 34 |
if (beginResult.type === 'replay' || beginResult.type === 'failed') {
|
| 35 |
requestId = beginResult.record.requestId;
|
| 36 |
return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), {
|
| 37 |
+
status: 202,
|
| 38 |
headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': requestId }
|
| 39 |
});
|
| 40 |
}
|
src/app/api/auth-status/route.test.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { GET } from './route';
|
| 2 |
+
import assert from 'node:assert/strict';
|
| 3 |
+
import { afterEach, describe, it } from 'node:test';
|
| 4 |
+
|
| 5 |
+
const originalAppPassword = process.env.APP_PASSWORD;
|
| 6 |
+
|
| 7 |
+
afterEach(() => {
|
| 8 |
+
if (originalAppPassword === undefined) {
|
| 9 |
+
delete process.env.APP_PASSWORD;
|
| 10 |
+
} else {
|
| 11 |
+
process.env.APP_PASSWORD = originalAppPassword;
|
| 12 |
+
}
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
describe('GET /api/auth-status', () => {
|
| 16 |
+
it('treats blank APP_PASSWORD as disabled', async () => {
|
| 17 |
+
process.env.APP_PASSWORD = ' ';
|
| 18 |
+
|
| 19 |
+
const response = await GET();
|
| 20 |
+
const body = (await response.json()) as { passwordRequired?: boolean };
|
| 21 |
+
|
| 22 |
+
assert.equal(body.passwordRequired, false);
|
| 23 |
+
});
|
| 24 |
+
});
|
src/app/api/auth-status/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
export async function GET() {
|
| 4 |
-
const appPasswordSet =
|
| 5 |
return NextResponse.json({ passwordRequired: appPasswordSet });
|
| 6 |
}
|
|
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
export async function GET() {
|
| 4 |
+
const appPasswordSet = Boolean(process.env.APP_PASSWORD?.trim());
|
| 5 |
return NextResponse.json({ passwordRequired: appPasswordSet });
|
| 6 |
}
|
src/app/api/auth-verify/route.test.ts
CHANGED
|
@@ -16,6 +16,21 @@ afterEach(() => {
|
|
| 16 |
});
|
| 17 |
|
| 18 |
describe('POST /api/auth-verify', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
it('returns a page access code error code for invalid access-code hashes', async () => {
|
| 20 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 21 |
const request = new NextRequest('http://localhost/api/auth-verify', {
|
|
|
|
| 16 |
});
|
| 17 |
|
| 18 |
describe('POST /api/auth-verify', () => {
|
| 19 |
+
it('treats blank APP_PASSWORD as disabled', async () => {
|
| 20 |
+
process.env.APP_PASSWORD = ' ';
|
| 21 |
+
const request = new NextRequest('http://localhost/api/auth-verify', {
|
| 22 |
+
method: 'POST',
|
| 23 |
+
headers: { 'Content-Type': 'application/json' },
|
| 24 |
+
body: JSON.stringify({})
|
| 25 |
+
});
|
| 26 |
+
|
| 27 |
+
const response = await POST(request);
|
| 28 |
+
const result = (await response.json()) as { authenticated?: boolean; passwordRequired?: boolean };
|
| 29 |
+
|
| 30 |
+
assert.equal(response.status, 200);
|
| 31 |
+
assert.deepEqual(result, { authenticated: true, passwordRequired: false });
|
| 32 |
+
});
|
| 33 |
+
|
| 34 |
it('returns a page access code error code for invalid access-code hashes', async () => {
|
| 35 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 36 |
const request = new NextRequest('http://localhost/api/auth-verify', {
|