misonL commited on
Commit
3e8ea5d
·
verified ·
1 Parent(s): 32151b6

Deploy 4566102 to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@45661020d068d46d33ab0321805725abdd88da69

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +21 -1
  2. .env.real-smoke.example +45 -0
  3. .gitignore +1 -0
  4. README.md +71 -27
  5. docker-compose.memory.yml +4 -0
  6. docs/deployment/huggingface-space-free.md +53 -108
  7. docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md +34 -0
  8. docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md +189 -0
  9. package.json +10 -2
  10. scripts/agent-doctor.mjs +57 -0
  11. scripts/agent-skill-scripts.test.mjs +1070 -3
  12. scripts/command-center-utils.mjs +132 -0
  13. scripts/command-center.test.mjs +429 -0
  14. scripts/deploy-hf-space.mjs +216 -0
  15. scripts/deploy-hf-space.test.mjs +91 -0
  16. scripts/deploy-local.mjs +112 -0
  17. scripts/doctor-hf-space.mjs +49 -123
  18. scripts/doctor.mjs +55 -0
  19. scripts/env-utils.mjs +15 -0
  20. scripts/env-utils.test.mjs +52 -0
  21. scripts/hf-space-doctor-utils.mjs +45 -32
  22. scripts/hf-space-doctor-utils.test.mjs +31 -4
  23. scripts/keepalive-hf-space.mjs +2 -16
  24. scripts/local-image-upstream-fixture.mjs +227 -0
  25. scripts/local-image-upstream-fixture.test.mjs +199 -0
  26. scripts/smoke-hf-space-memory.mjs +4 -1
  27. scripts/smoke-hf-space-memory.test.mjs +23 -0
  28. scripts/smoke-image-upstream-compat.mjs +310 -0
  29. scripts/smoke-image-upstream-local-final-gate.mjs +244 -0
  30. scripts/smoke-image-upstream-local-final-gate.test.mjs +49 -0
  31. scripts/smoke-image-upstream-real.mjs +791 -0
  32. scripts/smoke-image-upstream-real.test.mjs +1070 -0
  33. scripts/status.mjs +263 -0
  34. scripts/test-postgres-live.mjs +28 -3
  35. scripts/test-postgres-live.test.mjs +143 -0
  36. scripts/verify.mjs +86 -0
  37. skills/gpt-image-playground-agent/SKILL.md +50 -14
  38. skills/gpt-image-playground-agent/references/api.md +46 -8
  39. skills/gpt-image-playground-agent/scripts/edit-image.mjs +43 -0
  40. skills/gpt-image-playground-agent/scripts/generate-image.mjs +572 -50
  41. skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +8 -9
  42. src/app/api/agent/agent-routes.test.ts +1160 -94
  43. src/app/api/agent/capabilities/route.ts +2 -0
  44. src/app/api/agent/images/edit/route.ts +16 -5
  45. src/app/api/agent/images/generate/route.ts +14 -5
  46. src/app/api/agent/jobs/[id]/result/route.ts +2 -2
  47. src/app/api/agent/jobs/images/generate/route.ts +1 -0
  48. src/app/api/auth-status/route.test.ts +24 -0
  49. src/app/api/auth-status/route.ts +1 -1
  50. 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
- # 开启后仍需请求显式传入 imageBackend=responses,并单独配置 /responses 顶层模型。
 
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
- - 页面默认开启流式预览并发流式批处理仍默认关闭,只有设置 `ENABLE_STREAMING_BATCH=true` 后才会把 `n>1` 拆成多个流式任务。
143
- - 服务端会把官方 OpenAI Images 流式事件 OtokAPI `image.generation.*` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。
144
  - 流式请求失败时会显示原始错误状态和排查建议,不会自动改用非流式请求,以避免隐藏网关、限流或上游故障。
145
 
146
  ## 图片后端路径
147
 
148
- - 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。
149
- - OtokAPI 兼容仅发生在流式事件适配层:`image.generation.chunk` `image.generation.result` 会被归一化,不改变页面表单、Agent API批量逻辑
150
- - Responses API image generation 是实验路径,默认关闭。有同时设置 `ENABLE_RESPONSES_IMAGE_BACKEND=true`、配置 `OPENAI_RESPONSES_API_MODEL`,并在请求中式传入 `imageBackend=responses` 时服务端才调用 `/responses` 并读取 `image_generation_call.result`
 
 
 
151
  - Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;页面表单里的图片模型只传给 `image_generation` 工具。
152
- - Responses API 实验路径当前只支持非流式单张 `generate`,不替换默认 Images API,不接入现有 Agent 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,适合 4K/high 长耗时请求。 |
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
- | `ENABLE_RESPONSES_IMAGE_BACKEND` | 否 | `false` | 实验开关。显式设为 `true` 后,`imageBackend=responses` 请求调用 Responses API image generation。 |
304
- | `OPENAI_RESPONSES_API_MODEL` | 否 | | Responses API 实验后 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 |
 
 
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
- 不同用户首次接手自己的 Space 时,先生成本机访问记录文件
 
 
469
 
470
  ```bash
471
- npm run init-access:hf-space -- \
472
- --space-id <namespace>/<space-name> \
473
- --space-url https://<user>-<space>.hf.space
 
 
 
474
  ```
475
 
476
- 脚本会写入 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,生成访问码 `APP_PASSWORD` `AGENT_API_TOKEN`,并记录 `HF_SPACE_ID``HF_SPACE_URL` 和默认同步 key。脚不会打印 Secret 值,且默认不覆盖已有文件
477
- `HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。
478
 
479
- 创建本机访问记录文件不需要 Hugging Face 账号密码。同步 Secret 到远端 Space 需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token
480
 
481
  ```bash
482
- hf auth whoami
483
- hf auth login
484
  ```
485
 
486
- 如果不确当前机器缺什么,先运行只读诊断:
 
 
487
 
488
  ```bash
489
- npm run doctor:hf-space
490
  ```
491
 
492
- 命令检查 Node、npm、`hf` CLI、HF 状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置;不会写远端 Secret、不会重启 Space、不打印 Secret 值
 
 
493
 
494
- 如果修改了本机访问记录文件里的 `APP_PASSWORD`,可执行 `npm run sync-secret:hf-space` 同步到 Hugging Face Space Secret、重启并验证新访问码。脚本默认读取 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,并要求目标 Space 写在 access 文件或环境变量里,避免不同用户误写到示例 Space;输出不会回显 Secret 值。
 
 
 
 
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 doctor:hf-space` | 只读诊断 HF Space 部署前置条件本机 access 文件远端配置。 |
546
- | `npm run init-access:hf-space` | 为当前用户生成本机 HF Space 访问记录、随机访问码和 Agent token。 |
 
 
 
 
 
 
547
  | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
548
- | `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret验证页面访问码。 |
549
- | `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端Agent API 契约。 |
 
 
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` 和本地容器验证必需;只创建 txt 文件和同步 Secret 不需要 Docker
49
 
50
  安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
51
 
@@ -58,10 +58,58 @@ npm install
58
  如果不确定当前机器缺什么,运行只读诊断:
59
 
60
  ```bash
61
- npm run doctor:hf-space
62
  ```
63
 
64
- `doctor:hf-space` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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. 推送到 Space 仓库后等待构建完成
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
- resolve({ status, stdout, stderr });
 
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, statSync } from 'node:fs';
4
  import {
5
  assertKnownOptions,
6
  buildNextActions,
7
  classifyRequiredAndRecommendedNames,
8
- DEFAULT_ACCESS_FILE,
 
 
9
  getJsonNames,
10
  isMainModule,
11
- missingKeys,
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, ['--access-file', '--help', '-h', '--skip-remote']);
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 = runCommand(command, args);
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 checkAccessFile(checks, accessFile) {
74
- if (!existsSync(accessFile)) {
75
- addCheck(checks, 'fail', 'access-file', `Access file is missing: ${accessFile}`, {
76
- action: 'Run npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space'
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, values, skipRemote, hfAvailable, hfAuthenticated) {
161
  if (skipRemote) {
162
  addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.');
163
  return;
164
  }
165
- if (!hfAvailable || !hfAuthenticated || !values) {
166
- addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI, hf auth login, and a valid access file.');
167
  return;
168
  }
169
 
170
- const spaceId = values.get('HF_SPACE_ID')?.trim();
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 ${spaceId}.`, { error: info.error });
178
  return;
179
  }
180
- addCheck(checks, 'pass', 'remote-space', `Space ${spaceId} is accessible.`);
181
  checkRemoteNames(
182
  checks,
183
- spaceId,
184
  'remote-variables',
185
- ['spaces', 'variables', 'list', spaceId, '--json'],
186
  REQUIRED_SPACE_VARIABLES,
187
  RECOMMENDED_SPACE_VARIABLES
188
  );
189
- checkRemoteSecrets(checks, spaceId);
190
  }
191
 
192
  function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) {
193
- const result = runCommand('hf', args);
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 = runCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']);
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 ? runCommand('hf', ['auth', 'whoami']) : { ok: false };
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 = runCommand('docker', ['version', '--format', '{{.Server.Version}}']);
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 = runCommand('docker', ['--version']);
281
  if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.');
282
  }
283
 
284
- const values = checkAccessFile(checks, options.accessFile);
285
- checkRemote(checks, values, options.skipRemote, hfAvailable, Boolean(hfAuth.ok));
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 { readFileSync } from 'node:fs';
3
- import { join, resolve } from 'node:path';
4
  import { pathToFileURL } from 'node:url';
5
 
6
- export const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt');
 
 
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 runCommand(command, args = []) {
38
  const result = spawnSync(command, args, {
39
  encoding: 'utf8',
40
- stdio: ['ignore', 'pipe', 'pipe']
 
41
  });
42
- if (result.error) return { ok: false, error: result.error.message };
 
 
 
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
- 'access-file-keys',
113
- 'Regenerate or update the access file with npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space'
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
- if (!spaceId?.trim()) return 'HF Space id is required.';
137
- if (!/^[^/\s]+\/[^/\s]+$/.test(spaceId)) return 'HF Space id must use namespace/space format.';
 
 
 
 
 
 
 
 
 
 
 
 
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('bad'), /namespace\/space/);
 
 
 
 
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(['--access-file', 'tmp.txt'], ['--access-file']));
97
- assert.doesNotThrow(() => assertKnownOptions(['--access-file=tmp.txt'], ['--access-file']));
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() + 45_000;
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'] : options.silent ? 'ignore' : 'inherit',
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
- process.exit(result.status || 1);
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
- runTests(process.env.AGENT_POSTGRES_TEST_DATABASE_URL);
 
 
 
 
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`。同一操作重试复用原 key;操作要复用。
16
- 5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点是非流式端点,当前固定以 `stream: false` 上游。
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 默认路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明。
20
- 9. 读取 `agent_jobs`。 `supported=true` 且 `mode=job_polling`,4K/high 或长耗时任务优先走 job/polling
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
- 此时服务端只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一鉴权方式。
 
 
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` 支持流式;Agent generate/edit 当前以 `non_streaming_only` 声明
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
- 生成脚本会 capabilities 声明 `agent_jobs.supported=true` `quality=high` 且最大边不小于 3072 的请求自动使用 job polling。也可以用 `--job` 强制 job polling,或用 `--no-job` 强制同步 Agent generate。
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` 访问码部署时发送 `X-App-Password-Hash`。
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` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` `absolute_metadata_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`:当前部署实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。
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.high_4k_risk`:高质量 4K 级请求的长耗时风险说明。
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` 时,4K/high 长耗时请求优先创建 job 并轮询结果;同步 Agent generate 仍适用于普通非流式请求。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。
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 生成端点当前只支持非流式 JSON 响应。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。
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.jobMode),
77
- job_mode: options.jobMode,
 
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, requestBody, options.jobMode)) {
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
- jobMode: 'auto',
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.jobMode = 'always';
134
- else if (arg === '--no-job') parsed.jobMode = 'never';
 
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
- prompt: promptValue || 'contract check',
165
- model: parsed.model,
166
- n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
167
- size: parsed.size,
168
- quality: parsed.quality,
169
- output_format: normalizeOutputFormat(parsed.format),
170
- response_mode: parsed.responseMode
171
- };
 
 
 
172
  }
173
 
174
  function buildDryRunRequestBody(parsed) {
175
- const body = {
176
- model: parsed.model,
177
- n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
178
- size: parsed.size,
179
- quality: parsed.quality,
180
- output_format: normalizeOutputFormat(parsed.format),
181
- response_mode: parsed.responseMode
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(jobMode) {
221
- if (jobMode === 'always') return `${baseUrl}/api/agent/jobs/images/generate`;
222
- if (jobMode === 'never') return `${baseUrl}/api/agent/images/generate`;
223
- return `${baseUrl}/api/agent/images/generate 或 ${baseUrl}/api/agent/jobs/images/generate`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
224
  }
225
 
226
  async function readCapabilities() {
227
- const { response, result, text } = await fetchJson(`${baseUrl}/api/agent/capabilities`, {
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}/api/agent/images/generate`, {
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}/api/agent/jobs/images/generate`, {
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 || `/api/agent/jobs/${job.id}/result`, '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}/api/agent/images/generate`, {
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: '/api/agent/images/generate', status: response.status, error_code: result.error.code });
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}/api/agent/jobs/images/generate`, {
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: '/api/agent/jobs/images/generate',
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 fetchInit = { ...init };
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 shouldUseJobPolling(capabilitiesValue, request, jobMode) {
411
- if (jobMode === 'never') return false;
 
 
 
 
412
  if (!supportsJobPolling(capabilitiesValue)) {
413
- if (jobMode === 'always') {
414
- throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。');
 
 
 
 
 
 
 
 
415
  }
416
  return false;
417
  }
418
- if (jobMode === 'always') return true;
419
- return request.quality === 'high' && readMaxImageEdge(request.size) >= 3072;
 
 
 
 
 
 
 
 
 
 
 
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('常用参数:--model --size --quality --n --format --response-mode --timeout-ms --prompt-file --idempotency-key --job --no-job');
 
 
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
- if (value === undefined || value === null || value === '') return fallback;
13
- if (!/^\d+$/.test(String(value))) {
14
- throw new Error(`${name} 必须是正整数。`);
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('@/lib/agent-state-runtime');
 
 
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(agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' }));
 
 
130
  assert.equal(first.status, 200);
131
  const firstBody = await first.json();
132
  assert.equal(firstBody.cached, false);
133
 
134
- const second = await generateImage(agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' }));
 
 
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(agentJobJsonRequest('route-job-key', { prompt: 'agent route job' }));
 
 
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(agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' }));
 
 
360
  assert.equal(first.status, 202);
361
  const firstBody = await first.json();
362
  await waitFor(() => upstreamCalls === 1);
363
 
364
- const conflict = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' }));
 
 
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(agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }));
 
 
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(agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' }));
 
 
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('@/lib/agent-state-runtime');
 
 
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(agentEditRequest('route-edit-validation-key', 'agent edit invalid', {}, 'url'));
 
 
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(agentEditRequest('edit-completion-failure-key', 'state completion failure'));
 
 
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('artifact-auth-key', { prompt: 'artifact auth' }, { Authorization: 'Bearer artifact-token' })
 
 
 
 
731
  );
732
  const body = await generated.json();
733
  const artifactId = body.images[0].id;
734
 
735
- const denied = await getArtifactContent(new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), {
736
- params: Promise.resolve({ id: artifactId })
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('artifact-auth-key', { prompt: 'artifact auth' }, { Authorization: 'Bearer artifact-token' })
 
 
 
 
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(agentJsonRequest('artifact-missing-content-key', { prompt: 'missing content' }));
 
 
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(agentJsonRequest('completion-failure-key', { prompt: 'state completion failure' }));
 
 
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(agentJsonRequest('artifact-save-failure-key', { prompt: 'artifact save failure' }));
 
 
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('Agent route PostgreSQL integration', { skip: livePostgresUrl ? false : 'AGENT_POSTGRES_TEST_DATABASE_URL is not set' }, () => {
965
- it('allows only one upstream winner for concurrent identical idempotency requests', async () => {
966
- assert.ok(livePostgresUrl);
967
- const { generateImage } = await loadAgentRoutes();
968
- const schemaName = `agent_route_${Date.now().toString(36)}`;
969
- const pool = new Pool({ connectionString: livePostgresUrl });
970
- process.env.AGENT_STATE_BACKEND = 'postgres';
971
- process.env.AGENT_DATABASE_URL = `${livePostgresUrl}${livePostgresUrl.includes('?') ? '&' : '?'}options=-c%20search_path%3D${schemaName}`;
972
- process.env.AGENT_REQUEST_LEASE_MS = '60000';
973
- const admin = await pool.connect();
974
- let releaseUpstream: (() => void) | undefined;
975
- let upstreamCalls = 0;
976
- const upstream = await startImageUpstream(async () => {
977
- upstreamCalls += 1;
978
- await new Promise<void>((resolve) => {
979
- releaseUpstream = resolve;
 
 
 
 
 
980
  });
981
- return { data: [{ b64_json: PNG_BASE64 }] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
982
  });
983
- process.env.OPENAI_API_KEY = 'test-key';
984
- process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
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: jobResultRoute.GET,
1036
- getArtifact: artifactRoute.GET,
1037
- deleteArtifact: artifactRoute.DELETE,
1038
- getArtifactContent: artifactContentRoute.GET
 
 
 
 
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(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
 
 
 
 
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
- responseMode = 'path'
 
1071
  ) {
 
 
 
 
 
1072
  const formData = new FormData();
1073
  formData.append('prompt', prompt);
1074
  formData.append('model', 'gpt-image-2');
1075
- formData.append('response_mode', responseMode);
1076
- formData.append('image_0', new File([Buffer.from(PNG_BASE64, 'base64')], 'input.png', { type: 'image/png' }));
 
 
 
 
 
 
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
- async function startImageUpstream(handler: () => unknown | Promise<unknown>): Promise<{ baseUrl: string; close: () => Promise<void> }> {
 
 
 
 
 
 
 
 
 
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
- request.resume();
 
 
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 { filepath: string } | undefined;
 
 
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) => /\.(png|jpe?g|webp)$/i.test(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, { 'X-Idempotent-Replay': 'true' });
 
 
 
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, { 'X-Idempotent-Replay': 'true' });
 
 
 
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
- readCompletedJobResult
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 readCompletedJobResult(store, record);
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 = !!process.env.APP_PASSWORD;
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', {