diff --git a/.env.example b/.env.example index b86a54713ee2f3b4174755418c35e3f593f69b5a..60375dbacbf3878744ce7491fd0cb8d678fcef7d 100644 --- a/.env.example +++ b/.env.example @@ -43,12 +43,32 @@ OPENAI_API_BASE_URL= # OPENAI_MAX_STREAMS_PER_CREDENTIAL=1 # OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=60000 +# 可选:多上游图片兼容层默认策略。默认保持 Images API JSON 基线。 +# IMAGE_GENERATION_BACKEND=images-api +# IMAGE_STREAMING_STRATEGY=auto + # 可选:实验 Responses API 图片后端。默认关闭。 -# 开启后仍需请求显式传入 imageBackend=responses,并单独配置 /responses 顶层模型。 +# 开启后仍需请求显式传入 image_backend=responses-image-generation +# 或兼容别名 imageBackend=responses,并单独配置 /responses 顶层模型。 # 表单 model 只作为 image_generation 工具模型,不会复用为 /responses 顶层模型。 # ENABLE_RESPONSES_IMAGE_BACKEND=true # OPENAI_RESPONSES_API_MODEL= +# 可选:独立真实上游 smoke 目标。默认不触发计费请求,必须显式运行 --allow-billable。 +# 每组至少提供 BASE_URL 和 API_KEY;MODEL、SIZE、QUALITY、RESPONSES_MODEL 可按上游覆盖。 +# IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://original-new-api.example.com/v1 +# IMAGE_REAL_SMOKE_ORIGINAL_API_KEY= +# IMAGE_REAL_SMOKE_GAOREN_BASE_URL=https://gaoren-new-api.example.com/v1 +# IMAGE_REAL_SMOKE_GAOREN_API_KEY= +# IMAGE_REAL_SMOKE_SUB2API_BASE_URL=https://sub2api.example.com/v1 +# IMAGE_REAL_SMOKE_SUB2API_API_KEY= +# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL=https://sub2api.example.com/v1 +# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY= +# IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://gpt2image.example.com/v1 +# IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY= +# IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4 +# IMAGE_REAL_SMOKE_TIMEOUT_MS=240000 + # 可选:给网页加一个访问码。公网部署时建议一定要设置。 APP_PASSWORD= diff --git a/.env.real-smoke.example b/.env.real-smoke.example new file mode 100644 index 0000000000000000000000000000000000000000..563c4cdd3eef51b8d0162199d1c8f354180a587c --- /dev/null +++ b/.env.real-smoke.example @@ -0,0 +1,45 @@ +# Independent real upstream smoke targets. +# Copy this file to .env.real-smoke.local and fill only the targets you can run. +# Do not commit .env.real-smoke.local. +# Final gate: +# npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable + +# Original QuantumNous/new-api compatible Images API JSON. +IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL= +IMAGE_REAL_SMOKE_ORIGINAL_API_KEY= +# IMAGE_REAL_SMOKE_ORIGINAL_MODEL=gpt-image-2 +# IMAGE_REAL_SMOKE_ORIGINAL_SIZE=1024x1024 +# IMAGE_REAL_SMOKE_ORIGINAL_QUALITY=low + +# gaoren002/new-api Images API SSE and keepalive branch. +IMAGE_REAL_SMOKE_GAOREN_BASE_URL= +IMAGE_REAL_SMOKE_GAOREN_API_KEY= +# IMAGE_REAL_SMOKE_GAOREN_MODEL=gpt-image-2 +# IMAGE_REAL_SMOKE_GAOREN_SIZE=1024x1024 +# IMAGE_REAL_SMOKE_GAOREN_QUALITY=low + +# Wei-Shaw/sub2api Images API SSE. +IMAGE_REAL_SMOKE_SUB2API_BASE_URL= +IMAGE_REAL_SMOKE_SUB2API_API_KEY= +# IMAGE_REAL_SMOKE_SUB2API_MODEL=gpt-image-2 +# IMAGE_REAL_SMOKE_SUB2API_SIZE=1024x1024 +# IMAGE_REAL_SMOKE_SUB2API_QUALITY=low + +# sub2api Responses image_generation bridge. If omitted, the smoke script can reuse IMAGE_REAL_SMOKE_SUB2API_*. +IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL= +IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY= +# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2 +# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL=gpt-5.4 +# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_SIZE=1024x1024 +# IMAGE_REAL_SMOKE_SUB2API_RESPONSES_QUALITY=low + +# GPT2Image style Responses image_generation SSE. +IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL= +IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY= +# IMAGE_REAL_SMOKE_GPT2IMAGE_MODEL=gpt-image-2 +IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4 +# IMAGE_REAL_SMOKE_GPT2IMAGE_SIZE=1024x1024 +# IMAGE_REAL_SMOKE_GPT2IMAGE_QUALITY=low + +# Shared timeout for each real upstream smoke case. +IMAGE_REAL_SMOKE_TIMEOUT_MS=240000 diff --git a/.gitignore b/.gitignore index 12792ea38b2cd4ff609a8ae2932b3f17997109bb..4a165df835cf8cf4bc96fffe5026a74a84c8de67 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ dev-server*.log .env* !.env.example !.env.agent.example +!.env.real-smoke.example # vercel .vercel diff --git a/README.md b/README.md index 22bf2d734606c9e2385f2efffbd65461df8decd2..5ac5522c4b61c24fc30b4392d48f872c752be5e8 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ http://localhost:4783 - 内置遮罩工具:直接在图片上绘制遮罩,也可以上传 PNG 遮罩。 - 完整参数控制:模型、尺寸、质量、输出格式、压缩、背景、审核级别、生成数量。 - 4K 与自定义尺寸:支持 2K/4K 预设和手动输入宽高,并在前端校验尺寸约束。 -- 流式输出:默认开启,支持生成和编辑过程中的局部图片预览。 +- 流式输出:用户显式开启后支持生成和编辑过程中的局部图片预览。 - 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。 - 发送到编辑:从生成结果或历史记录直接进入编辑模式。 - 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。 @@ -139,17 +139,20 @@ http://localhost:4783 ## 默认行为 - 图片生成默认使用 `quality=high`。如需降低成本或让上游自行选择质量,可在页面或 Agent 请求中显式改为 `auto`、`medium` 或 `low`。 -- 页面默认开启流式预览;并发流式批处理仍默认关闭,只有设置 `ENABLE_STREAMING_BATCH=true` 后才会把 `n>1` 拆成多个流式任务。 -- 服务端会把官方 OpenAI Images 流式事件和 OtokAPI `image.generation.*` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。 +- 页面默认不发送流式请求;用户显式开启流式预览后,才会走 SSE 路径。并发流式批处理仍默认关闭,只有设置 `ENABLE_STREAMING_BATCH=true` 后才会把 `n>1` 拆成多个流式任务。 +- 服务端会把官方 OpenAI Images 流式事件、gaoren002/new-api 与 sub2api 图片 SSE、OtokAPI `image.generation.*`、Responses `image_generation_call` 事件统一映射为前端稳定的 `partial_image`、`completed`、`done`、`error` 事件。 - 流式请求失败时会显示原始错误状态和排查建议,不会自动改用非流式请求,以避免隐藏网关、限流或上游故障。 ## 图片后端路径 -- 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。 -- OtokAPI 兼容仅发生在流式事件适配层:`image.generation.chunk` 和 `image.generation.result` 会被归一化,不改变页面表单、Agent API 或批量逻辑。 -- Responses API image generation 是实验路径,默认关闭。只有同时设置 `ENABLE_RESPONSES_IMAGE_BACKEND=true`、配置 `OPENAI_RESPONSES_API_MODEL`,并在请求中显式传入 `imageBackend=responses` 时,服务端才会调用 `/responses` 并读取 `image_generation_call.result`。 +- 默认路径是服务端中继 OpenAI Images API:`/api/images` 调用上游 `/images/generations` 或 `/images/edits`,再返回本项目稳定的 JSON 或 SSE 协议。原版 new-api 和 sub2api 普通 JSON 能力保持这个基线。 +- 流式能力由请求字段或环境变量显式控制:`off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。`auto` 不会凭仓库名假设上游能力;Agent 辅助脚本对 `max_edge>2048` 的单次文生图默认优先使用页面端 `/api/images` SSE,失败后先诊断,再显式选择 Agent JSON 或 job 路径。 +- 流式请求在没有 partial image 前只显示连接保持状态,不会把 keepalive 当成图片预览或成功结果。 +- gaoren002/new-api、sub2api、OtokAPI 与 GPT2Image 风格 Responses 兼容仅发生在事件适配层:partial image 只作为预览,只有最终 completed base64 才会保存为 artifact;缺最终 base64 或仅返回远程 URL 会显式失败。 +- 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`。 +- Agent capabilities 会同时暴露 `supported.image_backends` 枚举和 `supported.enabled_image_backends` 当前启用后端;自动化脚本应以后者和 `image_backend_requirements` 判断 runtime 是否已准备好。 - Responses API 的顶层模型由 `OPENAI_RESPONSES_API_MODEL` 或请求字段 `responsesModel` 指定;页面表单里的图片模型只传给 `image_generation` 工具。 -- Responses API 实验路径当前只支持非流式单张 `generate`,不替换默认 Images API,不接入现有 Agent API、流式批处理或编辑表单。 +- Responses API 实验路径支持单张 `generate` 的非流式和上游 SSE 消费,不替换默认 Images API,不接入编辑表单。Agent generate 对外仍返回最终 JSON,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用服务端内部上游 SSE 消费。 ## 编辑与遮罩 @@ -199,7 +202,7 @@ Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统 | `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 | | `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 | | `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 | -| `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合 4K/high 或长耗时请求。 | +| `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合显式选择 job polling 的长耗时请求;大图单次文生图默认优先按 capabilities 使用页面端 `/api/images` SSE。 | | `GET /api/agent/jobs/{id}` | 轮询 job 状态。 | | `GET /api/agent/jobs/{id}/result` | 读取完成后的标准图片响应,运行中返回可重试错误。 | | `GET /api/agent/artifacts/{id}` | 查询产物元数据。 | @@ -300,8 +303,10 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case | `OPENAI_CHANNEL_N_API_KEYS` | 否 | 无 | 第 N 个渠道的一个或多个 API Key,多个 key 用英文逗号分隔。 | | `OPENAI_CHANNEL_N_FAILURE_COOLDOWN_MS` | 否 | 继承全局值 | 第 N 个渠道的失败冷却时间。 | | `ENABLE_STREAMING_BATCH` | 否 | `false` | 显式设为 `true` 后,流式模式下 `n>1` 会拆成多个 `n=1` 任务并发执行。 | -| `ENABLE_RESPONSES_IMAGE_BACKEND` | 否 | `false` | 实验开关。显式设为 `true` 后,`imageBackend=responses` 请求才可调用 Responses API image generation。 | -| `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 | +| `IMAGE_GENERATION_BACKEND` | 否 | `images-api` | 服务端默认图片后端,可选 `images-api` 或 `responses-image-generation`。请求字段可覆盖。 | +| `IMAGE_STREAMING_STRATEGY` | 否 | `auto` | 服务端默认流式兼容策略,可选 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。请求字段可覆盖。 | +| `ENABLE_RESPONSES_IMAGE_BACKEND` | 否 | `false` | 实验开关。显式设为 `true` 后,`image_backend=responses-image-generation` 或兼容别名 `imageBackend=responses` 请求才可调用 Responses API image generation。 | +| `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 Responses 图片后端时必须设置,或在请求中传 `responsesModel`。 | | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 | | `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 | | `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 | @@ -465,33 +470,44 @@ npm install 要求 Node.js 20 或更高版本。Hugging Face CLI 安装方式以官方文档为准;当前官方入口是 `hf` 命令,登录使用 Hugging Face Access Token。 -不同用户首次接手自己的 Space 时,先生成本机访问记录文件: +本仓库只保留一种推荐的管理员交互方式:先用顶层命令判断状态,再进入具体部署命令。Hugging Face Space 操作使用官方 `hf` CLI;不要再维护本机 access 文件,也不要把 Space Secret 写入仓库。 + +常用入口: ```bash -npm run init-access:hf-space -- \ - --space-id / \ - --space-url https://-.hf.space +npm run status +npm run doctor +npm run verify +npm run deploy:local +npm run deploy:space +npm run agent:doctor ``` -脚本会写入 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,生成访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`,并记录 `HF_SPACE_ID`、`HF_SPACE_URL` 和默认同步 key。脚本不会打印 Secret 值,且默认不覆盖已有文件。 -`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。 +`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 做只读契约检查。 -创建本机访问记录文件不需要 Hugging Face 账号密码。同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token: +如果只想诊断 HF Space 前置条件,可运行: ```bash -hf auth whoami -hf auth login +npm run doctor:hf-space ``` -如果不确定当前机器缺什么,先运行只读诊断: +该命令会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端 Variables 和远端 Secrets;不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 + +部署当前干净的 git HEAD 到固定 Space: ```bash -npm run doctor:hf-space +npm run deploy:space ``` -该命令会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置;不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 +该脚本会使用 `git archive HEAD` 生成临时源码目录,通过 `hf upload` 上传到 `misonL/gpt-image-playground-customer`,等待新 Space commit 进入 `RUNNING`,并执行只读公网端点检查。若工作区有未提交改动,脚本会直接失败,避免把本地临时状态误当成可复现发布。 + +配置或轮换 Space Secret 时,直接使用官方 `hf` CLI: -如果修改了本机访问记录文件里的 `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 值。 +```bash +hf spaces variables add misonL/gpt-image-playground-customer -e AGENT_STATE_BACKEND=memory +hf spaces secrets add misonL/gpt-image-playground-customer -s APP_PASSWORD= +hf spaces secrets add misonL/gpt-image-playground-customer -s AGENT_API_TOKEN= +``` `memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。 @@ -542,15 +558,43 @@ docker logs -f gpt-image-playground-customer | `npm run dev` | 启动本地开发服务。 | | `npm run build` | 执行生产构建。 | | `npm run start` | 启动生产模式服务。 | -| `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、本机 access 文件和远端配置。 | -| `npm run init-access:hf-space` | 为当前用户生成本机 HF Space 访问记录、随机访问码和 Agent token。 | +| `npm run status` | 只读输出 git、Node、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。 | +| `npm run doctor` | 运行统一诊断入口,默认包含 HF Space 只读远端检查。 | +| `npm run verify` | 执行提交前基线:测试、lint、脚本语法、构建和 `git diff --check`;加 `-- --postgres` 会包含 live PostgreSQL gate。 | +| `npm run deploy:local` | 重建本地 Docker 服务并探测 `/api/auth-status`、`/api/runtime-capabilities`、`/api/agent/capabilities`;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 | +| `npm run deploy:space` | 上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。 | +| `npm run agent:doctor` | 通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。 | +| `npm run deploy:hf-space` | 使用官方 `hf` CLI 上传当前干净 git HEAD 到固定 Space 并做只读公网验证。 | +| `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、固定 Space 目标和远端配置。 | | `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 | -| `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面访问码。 | -| `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约。 | +| `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约;慢机器可设置 `HF_SPACE_SMOKE_READY_TIMEOUT_MS`。 | +| `npm run smoke:image-upstream-compat` | 启动本地 mock 上游,验证 Images API、new-api/sub2api SSE 和 Responses image_generation 兼容契约。 | +| `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` 五个独立场景。 | +| `npm run smoke:image-upstream-real` | 检查真实上游 smoke 配置;加 `-- --allow-billable` 后才会触发真实生图。 | | `npm run lint` | 检查 `src/` 代码。 | | `npm run lint:scripts` | 跨平台检查仓库脚本和 skill 脚本语法。 | | `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 | +真实上游 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`。 + +`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` 后运行真实上游门禁。 + +若只需要验证当前 `.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` 调整。 + +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` 混淆。 + +最终验收独立真实上游时追加 `-- --require-independent-targets --allow-billable`。此时任一独立真实上游场景未被选中或被跳过都会让脚本以非零退出,并在 `unselected_required_cases`、`skipped_required_cases`、`missing_required_count` 和 `missing_required_cases` 中列出未完成的场景。若最终门禁预检发现必跑场景未选全、缺少配置或配置非法,脚本会阻断可运行目标并先失败,不触发任何真实上游计费调用。 + +`--include-server-channel` 只能验证当前 `.env.local` 服务端渠道,不能替代独立真实上游门禁。最终验收必须让以下 5 个独立场景都实际执行,且结果中 `skipped=false`: + +| 场景 ID | 验证对象 | 必需配置 | +| --- | --- | --- | +| `original-images-json` | 原版 QuantumNous/new-api 兼容 Images API JSON | `IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL`、`IMAGE_REAL_SMOKE_ORIGINAL_API_KEY` | +| `gaoren-images-sse` | gaoren002/new-api Images API SSE/keepalive 分支 | `IMAGE_REAL_SMOKE_GAOREN_BASE_URL`、`IMAGE_REAL_SMOKE_GAOREN_API_KEY` | +| `sub2api-images-sse` | Wei-Shaw/sub2api Images API SSE | `IMAGE_REAL_SMOKE_SUB2API_BASE_URL`、`IMAGE_REAL_SMOKE_SUB2API_API_KEY` | +| `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` | +| `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` | + ## 常见问题 ### 提示未检测到 Node.js diff --git a/docker-compose.memory.yml b/docker-compose.memory.yml index 197bff18cd80cda0be151ca3f2541e232ec20b68..f6a722dbcea3c487a90d851a999a74e2631bb3d8 100644 --- a/docker-compose.memory.yml +++ b/docker-compose.memory.yml @@ -1,8 +1,12 @@ services: gpt-image-playground: + build: + args: + NEXT_PUBLIC_IMAGE_STORAGE_MODE: indexeddb environment: AGENT_STATE_BACKEND: memory AGENT_SQLITE_PATH: "" AGENT_DATABASE_URL: "" AGENT_DB_PASSWORD: "" AGENT_DB_PASSWORD_FILE: "" + NEXT_PUBLIC_IMAGE_STORAGE_MODE: indexeddb diff --git a/docs/deployment/huggingface-space-free.md b/docs/deployment/huggingface-space-free.md index e9392fff62bbd5c0b5953c1cca3ba89fe91a11f6..4e020bee250275cc813e00dce2eaf9cb50841160 100644 --- a/docs/deployment/huggingface-space-free.md +++ b/docs/deployment/huggingface-space-free.md @@ -45,7 +45,7 @@ hf auth whoami - npm 随 Node.js 一起可用。 - Hugging Face CLI 使用当前官方 `hf` 命令。 - `hf auth login` 使用 Hugging Face Access Token,不是账号密码。 -- Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;只创建 txt 文件和同步 Secret 不需要 Docker。 +- Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;部署到远端 Space 使用 `hf` CLI。 安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。 @@ -58,10 +58,58 @@ npm install 如果不确定当前机器缺什么,运行只读诊断: ```bash -npm run doctor:hf-space +npm run doctor ``` -`doctor:hf-space` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 +`doctor` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、固定 Space 目标、远端 Variables 和远端 Secrets。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。 + +## 管理员命令中心 + +本仓库只保留一组稳定管理员入口: + +```bash +npm run status +npm run doctor +npm run verify +npm run deploy:local +npm run deploy:space +npm run agent:doctor +``` + +- `status`:只读输出 git、Node、固定 Space 目标、Agent capabilities 路径和 Skill 入口。 +- `doctor`:统一诊断入口,默认包含 HF Space 只读远端检查。 +- `verify`:提交前基线,执行测试、lint、脚本语法、构建和 `git diff --check`;需要真实 PostgreSQL gate 时加 `--postgres`。 +- `deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `--memory` 会断言 memory/indexeddb overlay 生效。 +- `deploy:space`:上传当前干净 git HEAD 到固定 HF Space,并做只读公网验证。 +- `agent:doctor`:通过仓库 Skill 脚本执行只读 Agent API 契约检查,不触发真实生图。 + +HF Space 交互使用官方 `hf` CLI。不要维护本机 access 文件,不要把 `APP_PASSWORD`、`AGENT_API_TOKEN`、OpenAI Key 或 Hugging Face token 写入仓库文件。 + +部署当前干净的 git HEAD 到固定 Space: + +```bash +npm run deploy:space +``` + +该脚本会: + +- 使用 `git status --porcelain` 拒绝脏工作区。 +- 使用 `git archive HEAD` 生成临时源码目录,只上传已跟踪源码。 +- 使用 `hf upload` 上传到 `misonL/gpt-image-playground-customer`。 +- 等待新 Space commit 进入 `RUNNING`。 +- 检查 `/api/auth-status`、`/api/agent/capabilities` 和 `/api/runtime-capabilities`,不触发真实生图。 + +配置或轮换 Variables/Secrets 时,直接使用官方 `hf` CLI: + +```bash +hf spaces variables add misonL/gpt-image-playground-customer -e AGENT_STATE_BACKEND=memory +hf spaces variables add misonL/gpt-image-playground-customer -e NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb +hf spaces variables add misonL/gpt-image-playground-customer -e APP_LOG_LEVEL=warn +hf spaces secrets add misonL/gpt-image-playground-customer -s APP_PASSWORD= +hf spaces secrets add misonL/gpt-image-playground-customer -s AGENT_API_TOKEN= +``` + +源码部署、远端诊断、Variables 和 Secrets 都围绕 `hf` CLI 完成;仓库内不再提供第二套 access-file 同步流程。 ## Space Variables @@ -160,6 +208,7 @@ npm run smoke:hf-space - 以 `AGENT_STATE_BACKEND=memory` 启动临时容器。 - 用手机 User-Agent 检查首页可访问。 - 检查 `/api/agent/capabilities` 返回 `state_backend=memory` 和 `image_storage_mode=indexeddb`。 +- 默认等待容器 HTTP ready 最多 45 秒;慢机器可设置 `HF_SPACE_SMOKE_READY_TIMEOUT_MS=90000`。 - 执行 Agent 生成和编辑脚本的契约检查,不触发真实上游生图。 ## 免费层限制 @@ -195,110 +244,6 @@ npm run keepalive:hf-space 注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。 -## 初始化本机访问记录 - -不同用户首次接手自己的 Space 时,先在本机生成访问记录文件。该文件保存在用户 home 目录下,不应提交到仓库: - -```bash -npm run init-access:hf-space -- \ - --space-id / \ - --space-url https://-.hf.space -``` - -默认写入: - -```text -~/.cache/gpt-image-playground-customer/hf-space-access.txt -``` - -文件会包含: - -```dotenv -HF_SPACE_ID=/ -HF_SPACE_URL=https://-.hf.space -HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN -APP_PASSWORD= -AGENT_API_TOKEN= -``` - -`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。 - -脚本不会在输出中回显 `APP_PASSWORD` 或 `AGENT_API_TOKEN`。如果文件已存在,默认拒绝覆盖;确认要重置时使用: - -```bash -npm run init-access:hf-space -- \ - --space-id / \ - --space-url https://-.hf.space \ - --force -``` - -创建这个 txt 文件不需要 Hugging Face 账号密码。它只保存本项目的访问码、Agent token 和 Space 目标信息。 - -同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token。先检查登录状态: - -```bash -hf auth whoami -``` - -如果未登录,执行: - -```bash -hf auth login -``` - -`hf auth login` 使用的是 Hugging Face Access Token,不是账号密码。不要把 HF 账号密码或 HF Access Token 写入 `hf-space-access.txt`。 - -生成后可先做本机只读诊断,不写远端: - -```bash -npm run doctor:hf-space -- --skip-remote -``` - -如果诊断提示 access 文件缺少 `HF_SPACE_ID`、`HF_SPACE_URL` 或 `HF_SPACE_SECRET_KEYS`,说明本机可能已有旧格式文件。可手工补齐这些字段,或确认重置后重新生成: - -```bash -npm run init-access:hf-space -- \ - --space-id / \ - --space-url https://-.hf.space \ - --force -``` - -## 同步本机访问码到 Space - -如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新访问码: - -```bash -npm run sync-secret:hf-space -``` - -默认读取: - -```text -~/.cache/gpt-image-playground-customer/hf-space-access.txt -``` - -由 `init-access:hf-space` 生成的文件会让同步脚本同时同步 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。旧格式文件默认只同步 `APP_PASSWORD`,不会在输出中回显访问码值。可通过环境变量覆盖目标或同步多个 key: - -```bash -HF_SPACE_ID=misonL/gpt-image-playground-customer \ -HF_SPACE_URL=https://misonl-gpt-image-playground-customer.hf.space \ -HF_SPACE_ACCESS_FILE=~/.cache/gpt-image-playground-customer/hf-space-access.txt \ -HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN \ -npm run sync-secret:hf-space -``` - -同步脚本默认要求 `HF_SPACE_ID` 和 `HF_SPACE_URL` 来自 access 文件或环境变量,避免不同用户误写到仓库示例 Space。只有维护默认示例 Space 时才使用: - -```bash -npm run sync-secret:hf-space -- --use-default-target -``` - -可选参数: - -- `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。 -- `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 访问码验证。 -- `npm run sync-secret:hf-space -- --use-default-target`:允许使用脚本内置默认 Space 目标。 - ## 验证门禁 最小验证: @@ -315,7 +260,7 @@ git diff --check 真实 Hugging Face gate: -1. 推送到 Space 仓库后等待构建完成。 +1. 提交代码后执行 `npm run deploy:space`,等待 Space 新 commit 进入 `RUNNING`。 2. 手机打开 Space 页面,确认能进入页面并发起一次真实生成。 3. 电脑执行 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 契约检查。 4. 如有可用测试额度,再执行一次真实 Agent 生成。 diff --git a/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md b/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md new file mode 100644 index 0000000000000000000000000000000000000000..c7d45ff9c1f26fe19981a47f29068a4f6c4368d7 --- /dev/null +++ b/docs/reviews/CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22.md @@ -0,0 +1,34 @@ +# CR-AGENT-ROUTING-LOCAL-FINAL-GATE-2026-05-22 + +## 范围 + +- Agent 图片端点路由契约、错误诊断与终态失败语义。 +- 本地图片上游 fixture 与 `smoke:image-upstream-real` final gate 复用路径。 +- 本记录只证明本地 fixture、脚本门禁与当前代码契约;不证明第三方真实部署当前可访问。 + +## 审计结论 + +- `/api/agent/capabilities` 现在暴露机器可读 `routing_rules`,`schema_version=2026-05-22`。 +- 高分辨率 Agent edit 请求在 route 层 `snapshotAgentEditFormData` 前被拒绝,服务层也在读取服务端 API 凭据前保留二道校验。 +- partial-only 上游 SSE 失败会保留 `upstream_event_type` 与 `partial_image_count`,但不会泄漏 partial base64。 +- 已进入终态 `failed` 的 Agent/job 回放会移除 `retry_after_seconds` 并返回 `retryable=false`。 +- 本地 final gate 启动仓库 fixture 后复用真实 smoke 脚本,跑满 5 个独立场景并要求 `final_gate_satisfied=true`。 +- 本地 final gate 会跳过 `.env.local` 加载,并清理 `IMAGE_REAL_SMOKE_*`、`OPENAI_*`、`APP_PASSWORD`、`AGENT_API_TOKEN` 等外层输入,避免 shell 凭据污染本地 fixture 验证。 + +## 验证记录 + +| 命令 | 退出码 | 摘要 | +| --- | --- | --- | +| `npm test` | 0 | 462 个测试通过;PostgreSQL live 子套件因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 | +| `npm run lint` | 0 | `eslint src` 通过。 | +| `npm run lint:scripts` | 0 | 脚本语法检查通过。 | +| `npm run build` | 0 | Next.js production build 通过,standalone runtime patch 完成。 | +| `node scripts/smoke-image-upstream-local-final-gate.mjs --timeout-ms 30000` | 0 | 5 个独立本地 fixture 场景全部通过,`final_gate_satisfied=true`。 | +| `git diff --check` | 0 | 当前 diff 无 whitespace error。 | +| 装饰符扫描 | 0 | 代码与 Markdown 改动中未发现 AGENTS.md 禁止的装饰性 Unicode 符号。 | +| `coderabbit review --prompt-only -t uncommitted` | 0 | CodeRabbit 返回 `findings=0`。 | + +## 剩余边界 + +- 本轮未运行真实第三方上游 `--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`。 +- 本轮未运行 live PostgreSQL gate;数据库真实行为仍以 `npm run test:postgres` 或 `npm run verify -- --postgres` 为准。 diff --git a/docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md b/docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md new file mode 100644 index 0000000000000000000000000000000000000000..8747caea6f39db44c63389b23bfd09e7eff7eba4 --- /dev/null +++ b/docs/reviews/CR-IMAGE-UPSTREAM-COMPAT-2026-05-21.md @@ -0,0 +1,189 @@ +# CR-IMAGE-UPSTREAM-COMPAT-2026-05-21 + +## 范围 + +- 多上游图片生成兼容层阶段验证。 +- 区分本地 mock 覆盖、当前真实上游 smoke、以及本机未配置的上游类型。 +- 记录 2026-05-21 在 `codex/image-upstream-compat` 分支上的验证口径。 + +## 当前真实上游范围 + +`.env.local` 当前只配置了 `superapi.buzz` 这一类真实上游,共 7 个服务端渠道。没有可直接区分的原版 QuantumNous/new-api、gaoren002/new-api、Wei-Shaw/sub2api 或独立 GPT2Image 部署地址。因此“原版 new-api / gaoren / sub2api / GPT2Image”差异主要由本地契约测试覆盖,真实 smoke 只能证明当前 `superapi.buzz` 上游在对应协议路径下的表现。 + +当前独立真实上游配置检查:`.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`。 + +## 真实 smoke 结果 + +运行方式:在当前 worktree 启动 `npx next dev --turbopack -p 4784`;Responses 路径使用 `ENABLE_RESPONSES_IMAGE_BACKEND=true OPENAI_RESPONSES_API_MODEL=gpt-5.4` 重启同端口服务。所有输出均只记录状态、事件类型、图片数量和 base64 长度,不记录 API key 或完整图片数据。 + +| 场景 | 结果 | 证据摘要 | +| --- | --- | --- | +| Images API 普通 JSON | 通过 | `/api/images` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1045668`,耗时 `19921ms`。 | +| Images API SSE / keepalive 策略 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, partial_image, completed, done`,`done_image_count=1`,耗时 `29615ms`。 | +| Responses image_generation 非流式 | 当前真实上游未通过 | `/api/images` 返回 `500 application/json`,错误为 `Responses API 未返回已完成的 image_generation_call.result。`。该结果说明当前真实上游的非流式 `/responses` 返回结构不满足本项目 final image 契约。 | +| Responses image_generation SSE 初测 | 暴露缺口后已修复 | 初测事件为 `partial_image, completed, error`,错误为 `response.output_item.done` 缺少图片 payload。修复后无图的 Responses output item done 会被忽略,仍要求整条流最终有 final image。 | +| Responses image_generation SSE 去重复测 | 通过 | `/api/images` 返回 `200 text/event-stream`,事件为 `partial_image, completed, done`,`done_image_count=1`,耗时 `46624ms`。 | +| Agent 内部 Images SSE | 通过 | `/api/agent/images/generate` 返回 `200 application/json`,`image_count=1`,产物 URL 为 `/api/agent/artifacts/.../content`,未返回客户端 SSE,耗时 `96081ms`。 | + +## 2026-05-22 脚本化真实复验 + +运行方式:`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 长度。 + +| 场景 | 结果 | 证据摘要 | +| --- | --- | --- | +| 当前服务端渠道 Images JSON 首次 | 显式失败 | `server-channel-images-json` 返回 `500 application/json`,`error=Connection error.`,耗时 `186537ms`,无图片。 | +| 当前服务端渠道 Images JSON 复测 | 通过 | `server-channel-images-json` 返回 `200 application/json`,`image_count=1`,`first_b64_length=1332092`,耗时 `164893ms`。 | +| 当前服务端渠道 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`。 | +| 当前服务端渠道 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`。 | +| 当前服务端渠道 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`。 | +| 当前服务端渠道 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`。 | + +## 本地契约覆盖 + +- `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。 +- `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 ` 加载独立真实 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` 新增图片产物。 +- `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/非对象忽略。 +- `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、以及流式请求参数。 +- `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 错误契约。 +- `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 失败。 +- `src/lib/agent-api-contracts.test.ts` 覆盖 capabilities/OpenAPI 中页面 SSE、Agent 内部 upstream SSE、最终响应契约、后端枚举、流式策略枚举、真正启用上游 SSE 的 activation 策略和默认非流式 Agent 策略。 +- `src/lib/image-stream-service.test.ts` 和 `src/lib/image-stream-collector.test.ts` 覆盖 Responses 流中同一 final image 跨事件重复到达、以及单个完成事件同时经 SDK/Responses 包装层重复抽取时只保存一份最终产物;同一事件内合法多图结果仍保留多张图片。 +- 浏览器 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` 为空,最终显示上述显式错误。 +- 运行态 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`。 + +## 2026-05-22 当前 worktree 基线复验 + +本轮补充复核:`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` 目录无产物残留。 + +| 命令 | 退出码 | 摘要 | +| --- | --- | --- | +| `npm test` | 0 | `425` 个测试通过,`0` 个失败。PostgreSQL live 测试因 `AGENT_POSTGRES_TEST_DATABASE_URL` 未配置跳过。 | +| `npm run lint` | 0 | `eslint src` 通过。 | +| `npm run lint:scripts` | 0 | `scripts/check-node-syntax.mjs` 通过。 | +| `npm run build` | 0 | Next.js 16.2.6 production build 通过,standalone runtime patch 完成。 | +| `npm run test:postgres` | 0 | 临时 `postgres:16-alpine` 容器内 55 个测试通过,覆盖 Agent route PostgreSQL 集成、Postgres schema/live concurrency、迁移、清理和 share metadata 契约。 | +| `npx tsc --noEmit` | 0 | 测试和源码 TypeScript 静态检查通过。 | +| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 | +| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 上游兼容场景全部通过。 | +| `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` 并返回图片。 | +| `npm run smoke:image-upstream-real -- --help` | 0 | help 输出列出 `--env-file `、`IMAGE_REAL_SMOKE_ORIGINAL_*`、`IMAGE_REAL_SMOKE_GAOREN_*`、`IMAGE_REAL_SMOKE_SUB2API_*`、`IMAGE_REAL_SMOKE_SUB2API_RESPONSES_*`、`IMAGE_REAL_SMOKE_GPT2IMAGE_*`。 | +| `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` 跳过,未触发真实生图。 | +| `npm run smoke:image-upstream-real -- --include-server-channel --case server-channel-responses-json` | 0 | 非计费 dry-run 通过;当前服务端 Responses JSON smoke 识别 `superapi.buzz` 渠道,因缺少 `--allow-billable` 跳过,未触发真实生图。 | +| `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` 跳过,未触发真实生图。 | +| `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`。 | +| `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`。 | +| `npm run smoke:image-upstream-real -- --env-file .env.real-smoke.example --require-independent-targets` | 1 | 预期失败;可提交模板中的空值不会被误判为已配置真实上游,5 个独立真实目标仍全部列入 `skipped_required_cases`。 | +| `git diff --check` | 0 | 当前 diff 无 whitespace error。 | +| `find generated-images/.real-smoke ...` | 0 | 当前 `generated-images/.real-smoke` 目录无新增 `png`、`jpg`、`jpeg`、`webp` 产物残留。 | + +## 2026-05-22 运行态契约复验 + +运行方式:`npx next dev --turbopack -p 4785` 启动本地服务后,只读请求 `GET /api/agent/capabilities`、`GET /api/agent/openapi.json`、`GET /api/runtime-capabilities`。 + +| 端点 | 结果 | 摘要 | +| --- | --- | --- | +| `/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`。 | +| `/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`。 | +| `/api/runtime-capabilities` | 通过 | `responsesImageBackend.enabled=false`,`mode=experimental`;运行态流式批量能力未默认开启,当前服务端渠道健康容量为 `healthyCredentialCount=7`、`healthyChannelCount=7`。 | + +## 2026-05-22 推送后补充复验 + +当前 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`,不改变业务运行代码。 + +| 命令或检查 | 退出码 | 摘要 | +| --- | --- | --- | +| `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` 全部通过。 | +| `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` 覆盖。 | +| `npx tsc --noEmit` | 0 | 修复后源码和测试 TypeScript 静态检查通过。 | +| `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 兼容场景通过。 | +| `npm audit --audit-level=high` | 0 | `found 0 vulnerabilities`。 | +| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍未配置,当前 `.env.local` 服务端渠道因缺少 `--allow-billable` 未触发生图。 | +| `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`。 | +| `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 响应中没有有效图片数据或文件名。”。 | +| `.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`,不能满足最终真实门禁。 | + +## 2026-05-22 status readiness 补充 + +在 `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。 + +在 `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`,避免配置非法时产生部分真实上游调用。 + +| 命令或检查 | 退出码 | 摘要 | +| --- | --- | --- | +| `node --test scripts/command-center.test.mjs` | 0 | 22 个脚本测试通过。 | +| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 22 个真实 smoke 脚本测试通过,新增覆盖 unsafe 独立上游 `BASE_URL` 的结构化 JSON 报告、非法配置时阻断其它 billable 上游调用,以及普通 billable smoke 顶层 `blocked_cases` 汇总。 | +| `npm run status` | 0 | `image_upstream_real_smoke.configuration_complete=false`、`configured_count=0`、`missing_count=5`,并列出 5 个独立真实上游目标缺失的 `BASE_URL` env。 | +| 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。 | +| 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。 | +| `npm run verify -- --postgres` | 0 | `npm test`、lint、script lint、build、live PostgreSQL gate、diff checks 全部通过。 | +| `npm run smoke:image-upstream-compat` | 0 | 7 个本地 mock 兼容场景通过。 | +| `npx tsc --noEmit` | 0 | TypeScript 静态检查通过。 | +| `npm run smoke:image-upstream-real -- --require-independent-targets` | 1 | 按最终门禁预期失败;仍缺 5 个独立真实上游目标,`final_gate_satisfied=false`。 | + +## 2026-05-22 final gate preflight 补充 + +本轮基线基于 `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 之前阻断已配置的可运行目标,先输出结构化失败,不发任何真实上游计费请求。该边界避免“最终门禁必然失败但已产生部分真实计费请求”的状态。 + +| 命令或检查 | 退出码 | 摘要 | +| --- | --- | --- | +| `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 兼容场景通过。 | +| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 23 个真实 smoke 脚本测试通过。新增覆盖最终独立门禁只配置一个目标但缺少其它必跑目标时,已配置目标被列入 `blocked_required_cases`,本地 mock 上游 `calls.length=0`。 | +| `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`,但由于独立目标配置不完整,仅输出缺失项报告,不进入上游调用路径。 | + +## 2026-05-22 Responses readiness 模型口径补充 + +本轮继续修正真实 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`。 + +| 命令或检查 | 退出码 | 摘要 | +| --- | --- | --- | +| `node --test scripts/command-center.test.mjs` | 0 | 新增覆盖 `IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL=gpt-image-2` 不会让 status readiness 误判 sub2api Responses 场景配置完成。 | +| `node --import tsx --test scripts/smoke-image-upstream-real.test.mjs` | 0 | 新增覆盖真实 smoke dry-run 同样不会把 sub2api Responses 图片模型变量当成 `/responses` 顶层模型。 | +| `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`。 | + +## 2026-05-22 GPT2Image Responses SSE 去重补充 + +本轮兼容 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 相同仍会保留为不同最终图片。 + +| 命令或检查 | 退出码 | 摘要 | +| --- | --- | --- | +| `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 只落一张。 | +| `npm run smoke:image-upstream-compat` | 0 | GPT2Image Responses SSE 场景恢复为 `partial_image, completed, done`,不再出现重复 `completed`。 | + +## 2026-05-22 fcdd698 基线复验 + +复验基线为 `fcdd698 Fix Responses upstream smoke readiness`,分支 `codex/image-upstream-compat` 已同步到 `origin/codex/image-upstream-compat`。本小节为后续追加的文档记录,不改变业务代码。PR #7 仍为 Draft/Open,`mergeStateStatus=CLEAN`,当前 GitGuardian Security Checks 通过。 + +| 命令或检查 | 退出码 | 摘要 | +| --- | --- | --- | +| `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`。 | +| `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。 | +| `npm run smoke:image-upstream-real -- --include-server-channel` | 0 | 非计费 dry-run 通过;5 个独立真实上游目标仍因缺少专用 `IMAGE_REAL_SMOKE_*_BASE_URL` 跳过,当前服务端渠道未触发真实生图。 | +| `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`。配置不完整时先输出缺失报告,不进入上游调用路径。 | +| `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`。 | +| `npm run verify` | 0 | `npm test`、`npm run lint`、`npm run lint:scripts`、`npm run build`、`git diff --check`、`git diff --cached --check` 全部通过。 | +| `find generated-images/.real-smoke ...` | 0 | 当前没有 `png`、`jpg`、`jpeg`、`webp` 真实 smoke 产物残留。 | + +本轮没有追加 `--allow-billable` 的真实生图请求。最终完成判定仍缺独立真实上游凭据和地址,需要配置 `.env.real-smoke.local` 后跑通最终门禁命令。 + +## 完成度审计矩阵 + +| 要求 | 当前证据 | 状态 | +| --- | --- | --- | +| 默认配置不破坏 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` 真实复测 | 已覆盖 | +| 流式能力只通过配置、UI 开关、Agent 显式策略或探测确认启用 | `src/lib/image-upstream-strategy.ts`、`src/lib/image-upstream-strategy.test.ts`、浏览器 UI smoke 默认未勾选流式、Agent defaults 为 `streaming_strategy=off` | 已覆盖 | +| 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 地址缺失 | +| 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 地址缺失 | +| 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 地址缺失 | +| 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 失败用例 | 已覆盖 | +| 缺 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` 显式失败记录 | 已覆盖 | +| 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 | 已覆盖 | +| 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 | 已覆盖 | +| 三类独立上游真实 smoke:原版 new-api、gaoren/new-api、sub2api/GPT2Image | `scripts/smoke-image-upstream-real.mjs` 已支持独立 `IMAGE_REAL_SMOKE_*` 目标和 `--env-file `;当前 dry-run 证明本机未配置专用 `BASE_URL`,`independent_targets` 汇总缺失目标并给出最终门禁命令,`missing_env_any` 指出缺失 env;`--require-independent-targets --allow-billable` 在配置不完整时会先失败并阻断部分真实调用;配置齐全后才允许最终门禁实际计费执行 | 未完成,缺少独立真实上游地址和 key | + +## 结论 + +- 当前实现保持默认 Images API JSON 基线,不会自动按仓库名启用流式能力。 +- 页面默认不发送 `stream=true`;用户显式开启流式后,在没有 partial image 前只显示连接保持状态,不把 keepalive 当成预览或成功。 +- 当前真实上游证明 Images JSON、Images SSE、Responses SSE、Agent 内部上游 SSE 可通过本项目稳定契约落到最终产物。 +- 当前真实上游的 Responses 非流式路径未返回符合契约的 `image_generation_call.result`,本项目按设计显式失败。 +- 因本机没有独立原版 new-api、gaoren new-api、sub2api 和 GPT2Image 地址,无法把这四类实现分别做真实 smoke;对应兼容行为以本地 mock 契约测试作为当前证据。 diff --git a/package.json b/package.json index aa977a3e4be6eb75995412381c204403e13b56cf..d438b35ae4008b592985aff9ab5ee512ea4d0709 100644 --- a/package.json +++ b/package.json @@ -10,11 +10,19 @@ "test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"", "test:scripts": "node --test \"scripts/**/*.test.mjs\"", "test:postgres": "node scripts/test-postgres-live.mjs", + "doctor": "node scripts/doctor.mjs", + "status": "node scripts/status.mjs", + "verify": "node scripts/verify.mjs", + "deploy:local": "node scripts/deploy-local.mjs", + "deploy:space": "npm run deploy:hf-space", + "agent:doctor": "node scripts/agent-doctor.mjs", + "deploy:hf-space": "node scripts/deploy-hf-space.mjs", "doctor:hf-space": "node scripts/doctor-hf-space.mjs", - "init-access:hf-space": "node scripts/init-hf-space-access.mjs", "keepalive:hf-space": "node scripts/keepalive-hf-space.mjs", - "sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs", "smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs", + "smoke:image-upstream-compat": "node --import tsx scripts/smoke-image-upstream-compat.mjs", + "smoke:image-upstream-local": "node scripts/smoke-image-upstream-local-final-gate.mjs", + "smoke:image-upstream-real": "node --import tsx -- scripts/smoke-image-upstream-real.mjs", "start": "node scripts/start-standalone.mjs", "lint": "eslint src", "lint:scripts": "node scripts/check-node-syntax.mjs", diff --git a/scripts/agent-doctor.mjs b/scripts/agent-doctor.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7633a66a98ad87b9a0e7ea209d3e1c02670ab779 --- /dev/null +++ b/scripts/agent-doctor.mjs @@ -0,0 +1,57 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'node:url'; + +import { isMainModule, parseJsonPayload, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs'; + +const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url)); +const AGENT_DOCTOR_TIMEOUT_MS = 75_000; + +export function buildAgentDoctorArgs() { + return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check']; +} + +function parseArgs(argv) { + const unknown = argv.find((arg) => !['--help', '-h'].includes(arg)); + if (unknown) throw new Error(`Unknown option: ${unknown}`); + return { + help: argv.includes('--help') || argv.includes('-h') + }; +} + +function printHelp() { + console.log(`Usage: + npm run agent:doctor + +Environment: + GPT_IMAGE_PLAYGROUND_URL Service base URL, defaults to http://localhost:4783. + GPT_IMAGE_AGENT_TOKEN Bearer token when capabilities require bearer auth. + GPT_IMAGE_APP_PASSWORD_HASH Password hash when capabilities require page password auth.`); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const result = runCommand(process.execPath, buildAgentDoctorArgs(), { + env: { ...process.env, GPT_IMAGE_AGENT_CONTRACT_CHECK: '1' }, + timeoutMs: AGENT_DOCTOR_TIMEOUT_MS + }); + if (!result.ok) { + printJson({ ok: false, command: 'agent:doctor', output: pickFailureOutput(result) }); + process.exit(1); + } + + const body = result.stdout ? parseJsonPayload(result.stdout, 'agent contract check') : {}; + printJson({ ok: true, command: 'agent:doctor', contract: body }); +} + +try { + if (isMainModule(import.meta.url, process.argv[1])) main(); +} catch (error) { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +} diff --git a/scripts/agent-skill-scripts.test.mjs b/scripts/agent-skill-scripts.test.mjs index f299a2683e2c137df18f8b57ceeaceb04511d5a8..a53b8419eea6dec4fc28fa761d1cc7dabce60fc6 100644 --- a/scripts/agent-skill-scripts.test.mjs +++ b/scripts/agent-skill-scripts.test.mjs @@ -76,6 +76,1026 @@ describe('Agent skill script argument validation', () => { assert.equal(result.stderr.trim(), ''); }); + it('includes explicit upstream streaming options in generate dry-run requests', () => { + const result = runSkillScript('generate-image.mjs', [ + '--image-backend', + 'responses', + '--streaming-strategy', + 'responses-sse', + '--partial-images', + '3', + 'prompt' + ]); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.request.image_backend, 'responses'); + assert.equal(body.request.streaming_strategy, 'responses-sse'); + assert.equal(body.request.partial_images, 3); + assert.equal(result.stderr.trim(), ''); + }); + + it('prints page SSE routing guidance for high-resolution generate dry-runs', () => { + const result = runSkillScript('generate-image.mjs', ['--size', '3072x2048', '--quality', 'high', 'prompt']); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.endpoint, 'http://localhost:4783/api/images'); + assert.equal(body.routing_guidance.recommended_endpoint, '/api/images'); + assert.equal(body.routing_guidance.transport, 'page_sse'); + assert.equal(body.routing_guidance.fallback_endpoint, '/api/agent/images/generate'); + assert.equal(body.routing_guidance.fallback_mode, 'manual_after_diagnosis'); + assert.equal(body.routing_guidance.reason.includes('max_edge>2048'), true); + assert.equal(result.stderr.trim(), ''); + }); + + it('does not automatically fall back after a billable page SSE generate failure', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end('data: {"type":"error","error":"stream failed","status":502}\n\n'); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected fallback' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_failed'); + assert.match(body.error.message, /stream failed/); + assert.equal(body.routing.fallback_endpoint, '/api/agent/images/generate'); + assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + } + ); + }); + + it('reports non-SSE page validation failures as non-billable', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(400, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'size 对 gpt-image-2 无效:宽高必须是 16 的倍数。' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '2049x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.billable, false); + assert.equal(body.error.code, 'page_sse_request_rejected'); + assert.equal(body.error.status, 400); + assert.match(body.error.message, /16 的倍数/); + assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + } + ); + }); + + it('reports non-JSON page 4xx failures as non-billable request rejections', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(401, { 'content-type': 'text/plain' }); + response.end('missing passwordHash'); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.billable, false); + assert.equal(body.error.code, 'page_sse_request_rejected'); + assert.equal(body.error.status, 401); + assert.match(body.error.message, /missing passwordHash/); + assert.equal(body.routing.fallback_mode, 'fix_request_before_retry'); + } + ); + }); + + it('reports non-SSE page server failures as billable page SSE failures', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'upstream page failed' } })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.billable, true); + assert.equal(body.error.code, 'page_sse_failed'); + assert.equal(body.error.status, 500); + assert.match(body.error.message, /upstream page failed/); + assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); + } + ); + }); + + it('preserves object page SSE error messages', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"error","error":{"message":"bad request from page","code":"validation_error","status":400}}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_failed'); + assert.equal(body.billable, true); + assert.equal(body.error.status, 400); + assert.match(body.error.message, /bad request from page/); + assert.doesNotMatch(body.error.message, /\[object Object\]/); + } + ); + }); + + it('fails large generate auto routing when page SSE capability is unavailable', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ agent_streaming: {}, agent_jobs: { supported: true, mode: 'job_polling' } })); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected fallback' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_unavailable'); + assert.equal(body.routing.fallback_mode, 'manual_after_diagnosis'); + assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']); + } + ); + }); + + it('fails page SSE output when the stream ends before the done event', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","path":"/api/image/image.png","output_format":"png"}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_failed'); + assert.match(body.error.message, /缺少最终 done 事件/); + } + ); + }); + + it('requires page access hash before calling page SSE when capabilities declare page auth', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: true, schemes: ['form-password-hash'], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected page call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_auth_required'); + assert.match(body.error.message, /GPT_IMAGE_APP_PASSWORD_HASH/); + assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']); + } + ); + }); + + it('rejects overlong page SSE client request ids before sending the stream request', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected page call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + [ + '--allow-billable', + '--page-sse', + '--size', + '3072x2048', + '--quality', + 'high', + 'prompt' + ], + { + GPT_IMAGE_PLAYGROUND_URL: baseUrl, + GPT_IMAGE_AGENT_IDEMPOTENCY_KEY: 'x'.repeat(129) + } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_client_request_id_too_long'); + assert.match(body.error.message, /不能超过 128 个字符/); + assert.deepEqual(requests.map((item) => `${item.method} ${item.url}`), ['GET /api/agent/capabilities']); + } + ); + }); + + it('strips page SSE base64 from default path-mode output', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","path":"/api/image/image.png","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"page-request-1","images":[{"filename":"image.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.images[0].filename, 'image.png'); + assert.equal('b64_json' in body.images[0], false); + assert.equal(body.images[0].path, '/api/image/image.png'); + assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/image.png`); + assert.equal(body.images[0].output_format, 'png'); + assert.equal(body.images[0].clientRequestId, 'page-request-1'); + } + ); + }); + + it('passes response mode through to page SSE form-data requests', async () => { + let pageRequestBody = ''; + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + pageRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"page-request-response-mode"}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', '--response-mode', 'base64', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.match(pageRequestBody, /name="response_mode"\r?\n\r?\nbase64/); + } + ); + }); + + it('omits page SSE streaming strategy unless explicitly requested', async () => { + let pageRequestBody = ''; + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + pageRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"page-request-default-streaming"}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--size', '1024x1024', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.match(pageRequestBody, /name="stream"\r?\n\r?\ntrue/); + assert.doesNotMatch(pageRequestBody, /name="image_streaming_strategy"/); + } + ); + }); + + it('enforces timeout while waiting for page SSE body events', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write(': keepalive\n\n'); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--timeout-ms', '250', '--size', '1024x1024', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl }, + { timeoutMs: 1_200 } + ); + + assert.equal(result.timedOut, false); + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const body = JSON.parse(result.stderr); + assert.equal(body.error.code, 'page_sse_failed'); + assert.match(body.error.message, /\/api\/images/); + } + ); + }); + + it('passes page access hash through to page SSE form-data requests when configured', async () => { + let pageRequestBody = ''; + await withServer( + async (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: true, schemes: ['form-password-hash'], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + pageRequestBody = await readRequestText(request); + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","b64_json":"final-base64","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"page-request-password"}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { + GPT_IMAGE_PLAYGROUND_URL: baseUrl, + GPT_IMAGE_APP_PASSWORD_HASH: 'hash-for-page-sse' + } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.match(pageRequestBody, /name="passwordHash"\r?\n\r?\nhash-for-page-sse/); + } + ); + }); + + it('keeps page SSE base64 in default path-mode output when no path is returned', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image.png","b64_json":"indexeddb-base64","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"page-request-indexeddb"}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.images[0].filename, 'image.png'); + assert.equal(body.images[0].b64_json, 'indexeddb-base64'); + assert.equal('absolute_path' in body.images[0], false); + assert.equal(body.images[0].clientRequestId, 'page-request-indexeddb'); + } + ); + }); + + it('preserves completed page SSE images when the done event lists fewer images', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { + supported: true, + endpoint: '/api/images', + auth: { required: false, schemes: [], form_field: 'passwordHash' } + } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"image-a.png","b64_json":"base64-a","path":"/api/image/image-a.png","output_format":"png"}', + '', + 'data: {"type":"completed","filename":"image-b.png","b64_json":"base64-b","path":"/api/image/image-b.png","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"page-request-2","images":[{"filename":"image-a.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.images.length, 2); + assert.equal(body.images[0].filename, 'image-a.png'); + assert.equal(body.images[1].filename, 'image-b.png'); + assert.equal(body.images[1].path, '/api/image/image-b.png'); + assert.equal(body.images[1].absolute_path, `${baseUrl}/api/image/image-b.png`); + assert.equal(body.images[1].clientRequestId, 'page-request-2'); + } + ); + }); + + it('uses Agent JSON for billable large generate requests when --agent is explicit', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + images: [ + { + filename: 'agent.png', + content_url: '/api/agent/artifacts/artifact-1/content', + metadata_url: '/api/agent/artifacts/artifact-1' + } + ] + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected page SSE call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--agent', '--size', '3072x2048', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.images[0].filename, 'agent.png'); + assert.equal(body.images[0].absolute_content_url, `${baseUrl}/api/agent/artifacts/artifact-1/content`); + assert.deepEqual(body.routing, { transport: 'agent_json', endpoint: '/api/agent/images/generate' }); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/agent/images/generate'] + ); + } + ); + }); + + it('uses page SSE for billable small generate requests when --page-sse is explicit', async () => { + const requests = []; + await withServer( + (request, response) => { + requests.push({ method: request.method, url: request.url }); + if (request.url === '/api/agent/capabilities') { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + agent_streaming: { + page_sse: { supported: true, endpoint: '/api/images' } + }, + agent_jobs: { supported: true, mode: 'job_polling' } + }) + ); + return; + } + if (request.url === '/api/images') { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.end( + [ + 'data: {"type":"completed","filename":"small-page.png","path":"/api/image/small-page.png","output_format":"png"}', + '', + 'data: {"type":"done","client_request_id":"small-page-request","images":[{"filename":"small-page.png"}]}', + '', + '' + ].join('\n') + ); + return; + } + if (request.url === '/api/agent/images/generate') { + response.writeHead(500, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'unexpected Agent JSON call' })); + return; + } + response.writeHead(404, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ error: 'missing' })); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--page-sse', '--size', '1024x1024', '--quality', 'high', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const body = JSON.parse(result.stdout); + assert.equal(body.images[0].filename, 'small-page.png'); + assert.equal(body.images[0].absolute_path, `${baseUrl}/api/image/small-page.png`); + assert.deepEqual(body.routing, { transport: 'page_sse', endpoint: '/api/images' }); + assert.deepEqual( + requests.map((item) => `${item.method} ${item.url}`), + ['GET /api/agent/capabilities', 'POST /api/images'] + ); + } + ); + }); + + it('prints page SSE guidance for high-resolution edit dry-runs', () => { + const result = runSkillScript('edit-image.mjs', [ + '--size', + '3072x2048', + '--quality', + 'high', + '/tmp/source.png', + 'prompt' + ]); + + assert.equal(result.status, 0); + const body = JSON.parse(result.stdout); + assert.equal(body.routing_guidance.recommended_endpoint, '/api/images'); + assert.equal(body.routing_guidance.transport, 'page_sse'); + assert.equal(body.routing_guidance.strength, 'must_use'); + assert.equal(result.stderr.trim(), ''); + }); + + it('blocks billable high-resolution Agent edit requests before reading image files', () => { + const result = runSkillScript('edit-image.mjs', [ + '--allow-billable', + '--size', + '3072x2048', + '/tmp/missing-source.png', + 'prompt' + ]); + + assert.equal(result.status, 2); + const body = JSON.parse(result.stderr); + assert.equal(body.billable, false); + assert.equal(body.routing_guidance.recommended_endpoint, '/api/images'); + assert.equal(body.routing_guidance.strength, 'must_use'); + assert.equal(result.stdout.trim(), ''); + }); + + it('rejects invalid generate upstream streaming options before dry-run output', () => { + const invalidPartialImages = runSkillScript('generate-image.mjs', ['--partial-images', '4', 'prompt']); + assert.equal(invalidPartialImages.status, 2); + assert.match(invalidPartialImages.stderr, /--partial-images 必须是 1 到 3 的整数/); + assert.equal(invalidPartialImages.stdout.trim(), ''); + + const invalidBackend = runSkillScript('generate-image.mjs', ['--image-backend', 'unknown-backend', 'prompt']); + assert.equal(invalidBackend.status, 2); + assert.match(invalidBackend.stderr, /--image-backend 必须是/); + assert.equal(invalidBackend.stdout.trim(), ''); + + const invalidStrategy = runSkillScript('generate-image.mjs', [ + '--streaming-strategy', + 'unknown-strategy', + 'prompt' + ]); + assert.equal(invalidStrategy.status, 2); + assert.match(invalidStrategy.stderr, /--streaming-strategy 必须是/); + assert.equal(invalidStrategy.stdout.trim(), ''); + + const invalidResponseMode = runSkillScript('generate-image.mjs', ['--response-mode', 'url', 'prompt']); + assert.equal(invalidResponseMode.status, 2); + assert.match(invalidResponseMode.stderr, /--response-mode 必须是 path、base64 或 both/); + assert.equal(invalidResponseMode.stdout.trim(), ''); + + const disabledAutoPageSse = runSkillScript('generate-image.mjs', [ + '--size', + '3072x2048', + '--streaming-strategy', + 'off', + 'prompt' + ]); + assert.equal(disabledAutoPageSse.status, 0); + const disabledAutoPageSseBody = JSON.parse(disabledAutoPageSse.stdout); + assert.equal(disabledAutoPageSseBody.endpoint, 'http://localhost:4783/api/agent/images/generate'); + assert.equal(disabledAutoPageSseBody.routing_guidance.recommended_endpoint, '/api/agent/images/generate'); + assert.equal(disabledAutoPageSse.stderr.trim(), ''); + + const disabledPageSse = runSkillScript('generate-image.mjs', [ + '--page-sse', + '--streaming-strategy', + 'off', + 'prompt' + ]); + assert.equal(disabledPageSse.status, 2); + assert.match(disabledPageSse.stderr, /streaming_strategy=off/); + assert.doesNotMatch(disabledPageSse.stderr, /ModuleJob|at buildGenerateRoutingGuidance/); + assert.equal(disabledPageSse.stdout.trim(), ''); + }); + it('shows edit help without validating unrelated env values', () => { const result = runSkillScript('edit-image.mjs', ['--help'], { GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc' @@ -138,6 +1158,31 @@ describe('Agent skill script argument validation', () => { } ); }); + + it('does not duplicate network failure prefixes in generate capability errors', async () => { + await withServer( + (request, response) => { + if (request.url === '/api/agent/capabilities') { + return; + } + response.writeHead(404, { 'content-type': 'text/plain' }); + response.end('missing'); + }, + async (baseUrl) => { + const result = await runSkillScriptAsync( + 'generate-image.mjs', + ['--allow-billable', '--timeout-ms', '50', 'prompt'], + { GPT_IMAGE_PLAYGROUND_URL: baseUrl } + ); + + assert.equal(result.status, 1); + assert.equal(result.stdout.trim(), ''); + const prefixes = result.stderr.match(/请求失败:/g) || []; + assert.equal(prefixes.length, 1); + assert.match(result.stderr, /\/api\/agent\/capabilities/); + } + ); + }); }); function runSkillScript(filename, args, env = {}) { @@ -148,7 +1193,7 @@ function runSkillScript(filename, args, env = {}) { }); } -function runSkillScriptAsync(filename, args, env = {}) { +function runSkillScriptAsync(filename, args, env = {}, options = {}) { return new Promise((resolve) => { const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], { cwd: repoRoot, @@ -157,6 +1202,13 @@ function runSkillScriptAsync(filename, args, env = {}) { }); let stdout = ''; let stderr = ''; + let timedOut = false; + const timeout = options.timeoutMs + ? setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, options.timeoutMs) + : undefined; child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8'); child.stdout.on('data', (chunk) => { @@ -165,8 +1217,9 @@ function runSkillScriptAsync(filename, args, env = {}) { child.stderr.on('data', (chunk) => { stderr += chunk; }); - child.on('close', (status) => { - resolve({ status, stdout, stderr }); + child.on('close', (status, signal) => { + if (timeout) clearTimeout(timeout); + resolve({ status, signal, stdout, stderr, timedOut }); }); }); } @@ -183,3 +1236,17 @@ async function withServer(handler, run) { await new Promise((resolve) => server.close(resolve)); } } + +function readRequestText(request) { + return new Promise((resolve, reject) => { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + request.on('end', () => { + resolve(body); + }); + request.on('error', reject); + }); +} diff --git a/scripts/command-center-utils.mjs b/scripts/command-center-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a12c5424e30eb482a2639ba816f2d3d1845d10a7 --- /dev/null +++ b/scripts/command-center-utils.mjs @@ -0,0 +1,132 @@ +import { spawnSync } from 'node:child_process'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +const DEFAULT_MAX_BUFFER = 20 * 1024 * 1024; +const DEFAULT_FETCH_TIMEOUT_MS = 5000; + +export function isMainModule(moduleUrl, argvPath) { + return Boolean(argvPath) && moduleUrl === pathToFileURL(resolve(argvPath)).href; +} + +export function runCommand(command, args = [], options = {}) { + const startedAt = Date.now(); + const result = spawnSync(command, args, { + cwd: options.cwd, + encoding: options.encoding || 'utf8', + env: options.env, + input: options.input, + maxBuffer: options.maxBuffer || DEFAULT_MAX_BUFFER, + stdio: ['pipe', 'pipe', 'pipe'], + timeout: options.timeoutMs + }); + const elapsedMs = Date.now() - startedAt; + if (result.error) { + return { + ok: false, + command, + args, + status: result.status, + stdout: String(result.stdout || ''), + stderr: String(result.stderr || ''), + elapsed_ms: elapsedMs, + error: result.error.message, + signal: result.signal + }; + } + return { + ok: result.status === 0, + command, + args, + status: result.status, + stdout: String(result.stdout || ''), + stderr: String(result.stderr || ''), + elapsed_ms: elapsedMs, + signal: result.signal + }; +} + +export function runCommandStrict(command, args = [], options = {}) { + const result = runCommand(command, args, options); + if (!result.ok) throw new Error(commandFailureMessage(result)); + return result.stdout; +} + +export function commandFailureMessage(result) { + return pickFailureOutput(result) || `${result.command} ${result.args.join(' ')} failed`; +} + +export function printJson(payload) { + console.log(JSON.stringify(payload, null, 2)); +} + +export function parseJsonPayload(output, label = 'command') { + const lines = output.split(/\r?\n/); + let lastError; + let sawCandidate = false; + for (let start = 0; start < lines.length; start += 1) { + const trimmed = lines[start].trimStart(); + if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) continue; + sawCandidate = true; + for (let end = lines.length; end > start; end -= 1) { + try { + return JSON.parse(lines.slice(start, end).join('\n')); + } catch (error) { + lastError = error; + } + } + } + if (!sawCandidate) throw new Error(`${label} did not return JSON output.`); + throw new Error(`${label} returned invalid JSON: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +} + +export async function fetchJsonWithTimeout(url, options = {}) { + const timeoutMs = options.timeoutMs || DEFAULT_FETCH_TIMEOUT_MS; + const pathname = safeUrlPathname(url); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { signal: controller.signal }); + const text = await response.text(); + const bodySnippet = formatResponseBodySnippet(text); + if (!response.ok) throw new Error(`${pathname} failed with HTTP ${response.status}${bodySnippet}`); + try { + return JSON.parse(text); + } catch (error) { + if (error instanceof SyntaxError) throw new Error(`${pathname} did not return JSON${bodySnippet}`); + throw error; + } + } catch (error) { + if (error?.name === 'AbortError') throw new Error(`${pathname} timed out after ${timeoutMs}ms.`); + throw error; + } finally { + clearTimeout(timer); + } +} + +function formatResponseBodySnippet(text) { + if (!text || process.env.NODE_ENV === 'production') return ''; + return `: ${text.slice(0, 100)}`; +} + +function safeUrlPathname(url) { + try { + return new URL(url).pathname; + } catch { + return String(url); + } +} + +export function pickFailureOutput(result, maxLength = 4000) { + const output = [ + result.stdout, + result.stderr, + result.status !== undefined && result.status !== null ? `status: ${result.status}` : '', + result.error ? `error: ${result.error}` : '', + result.signal ? `signal: ${result.signal}` : '' + ] + .filter(Boolean) + .join('\n') + .trim(); + return output.length > maxLength ? `${output.slice(0, maxLength)}...` : output; +} diff --git a/scripts/command-center.test.mjs b/scripts/command-center.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..1e0e8f2dcb1661d93214e8aa37c8f7e27c4c387c --- /dev/null +++ b/scripts/command-center.test.mjs @@ -0,0 +1,429 @@ +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import { buildAgentDoctorArgs } from './agent-doctor.mjs'; +import { fetchJsonWithTimeout, parseJsonPayload, pickFailureOutput, runCommand } from './command-center-utils.mjs'; +import { + buildAdminCommands, + buildImageUpstreamRealSmokeStatus, + parseGitStatusEntries, + readStatusEnvFromFiles, + readRemoteStatusFromResult +} from './status.mjs'; +import { assertLocalProbeMatchesMode, buildDockerComposeArgs, buildDockerComposeEnv } from './deploy-local.mjs'; +import { buildVerifyPlan } from './verify.mjs'; + +describe('Command center scripts', () => { + it('exposes a small stable administrator command set', () => { + assert.deepEqual(buildAdminCommands(), { + doctor: 'npm run doctor', + status: 'npm run status', + verify: 'npm run verify', + deploy_local: 'npm run deploy:local', + deploy_space: 'npm run deploy:space', + agent_doctor: 'npm run agent:doctor', + hf_space_doctor: 'npm run doctor:hf-space', + hf_space_smoke: 'npm run smoke:hf-space' + }); + }); + + it('keeps the full verification gate aligned with repository policy', () => { + assert.deepEqual( + buildVerifyPlan().map((step) => step.name), + ['test', 'lint', 'lint:scripts', 'build', 'diff-check', 'diff-cached-check'] + ); + }); + + it('supports a quick verification loop without hiding the full gate', () => { + assert.deepEqual( + buildVerifyPlan({ quick: true }).map((step) => step.name), + ['test:scripts', 'lint:scripts', 'diff-check', 'diff-cached-check'] + ); + assert.deepEqual( + buildVerifyPlan({ skipBuild: true }).map((step) => step.name), + ['test', 'lint', 'lint:scripts', 'diff-check', 'diff-cached-check'] + ); + }); + + it('can include the live PostgreSQL gate before the final diff check', () => { + assert.deepEqual( + buildVerifyPlan({ postgres: true }).map((step) => step.name), + ['test', 'lint', 'lint:scripts', 'build', 'test:postgres', 'diff-check', 'diff-cached-check'] + ); + assert.deepEqual( + buildVerifyPlan({ quick: true, postgres: true }).map((step) => step.name), + ['test:scripts', 'lint:scripts', 'test:postgres', 'diff-check', 'diff-cached-check'] + ); + }); + + it('parses NUL-delimited git porcelain paths without rewriting filenames', () => { + const output = [ + ' M README.md', + '?? path with spaces.md', + 'R new name.md', + 'old name.md', + ' M path -> not rename.md', + ' M line\nbreak.md', + '' + ].join('\0'); + + assert.deepEqual(parseGitStatusEntries(output), [ + 'README.md', + 'path with spaces.md', + 'new name.md', + 'path -> not rename.md', + 'line\nbreak.md' + ]); + }); + + it('summarizes independent image upstream smoke readiness without exposing credentials', () => { + const missing = buildImageUpstreamRealSmokeStatus({}); + assert.equal(missing.configuration_complete, false); + assert.equal(missing.configured_count, 0); + assert.deepEqual(missing.missing_cases, [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.deepEqual(missing.missing_env_any['sub2api-responses-json'][0], [ + 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL', + 'IMAGE_REAL_SMOKE_SUB2API_BASE_URL' + ]); + assert.equal( + missing.final_gate_command, + 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable' + ); + + const configured = buildImageUpstreamRealSmokeStatus({ + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://original.example/v1', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'original-secret', + IMAGE_REAL_SMOKE_GAOREN_BASE_URL: 'https://gaoren.example/v1', + IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'gaoren-secret', + IMAGE_REAL_SMOKE_SUB2API_BASE_URL: 'https://sub2api.example/v1', + IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'sub2api-secret', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL: 'gpt-4.1', + IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: 'https://gpt2image.example/v1', + IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'gpt2image-secret', + IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL: 'gpt-5.4' + }); + assert.equal(configured.configuration_complete, true); + assert.equal(configured.configured_count, 5); + assert.equal(configured.missing_count, 0); + assert.doesNotMatch(JSON.stringify(configured), /secret|example\/v1/); + }); + + it('requires Responses top-level models in image upstream status readiness', () => { + const status = buildImageUpstreamRealSmokeStatus({ + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://original.example/v1', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'original-secret', + IMAGE_REAL_SMOKE_GAOREN_BASE_URL: 'https://gaoren.example/v1', + IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'gaoren-secret', + IMAGE_REAL_SMOKE_SUB2API_BASE_URL: 'https://sub2api.example/v1', + IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'sub2api-secret', + IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: 'https://gpt2image.example/v1', + IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'gpt2image-secret' + }); + + assert.equal(status.configuration_complete, false); + assert.equal(status.configured_count, 3); + assert.deepEqual(status.configured_cases, [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse' + ]); + assert.deepEqual(status.missing_cases, ['sub2api-responses-json', 'gpt2image-responses-sse']); + assert.deepEqual(status.missing_env_any['sub2api-responses-json'][0], [ + 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL', + 'OPENAI_RESPONSES_API_MODEL' + ]); + assert.deepEqual(status.missing_env_any['gpt2image-responses-sse'][0], [ + 'IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL', + 'OPENAI_RESPONSES_API_MODEL' + ]); + assert.doesNotMatch(JSON.stringify(status), /secret|example\/v1/); + }); + + it('does not treat the sub2api Responses image model as the Responses top-level model', () => { + const status = buildImageUpstreamRealSmokeStatus({ + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: 'https://sub2api-responses.example/v1', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'sub2api-responses-secret', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL: 'gpt-image-2' + }); + + assert.equal(status.configuration_complete, false); + assert.equal(status.configured_count, 0); + assert.deepEqual(status.missing_cases, [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.deepEqual(status.missing_env_any['sub2api-responses-json'][0], [ + 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL', + 'OPENAI_RESPONSES_API_MODEL' + ]); + assert.doesNotMatch(JSON.stringify(status), /sub2api-responses-secret|sub2api-responses\.example/); + }); + + it('reports unsafe independent image upstream base URLs without exposing values', () => { + const status = buildImageUpstreamRealSmokeStatus({ + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@original.example/v1?token=secret#frag', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'original-secret' + }); + + assert.equal(status.configuration_complete, false); + assert.equal(status.configured_count, 0); + assert.equal(status.missing_count, 4); + assert.equal(status.invalid_count, 1); + assert.deepEqual(status.invalid_cases, ['original-images-json']); + assert.deepEqual(status.invalid_env['original-images-json'], [ + { + key: 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL', + reason: 'must_not_include_credentials' + } + ]); + assert.equal(status.missing_env_any['original-images-json'], undefined); + assert.doesNotMatch(JSON.stringify(status), /user:pass|original\.example|token=secret|original-secret/); + }); + + it('loads independent image upstream smoke readiness from env files without overriding shell env', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-upstream-status-')); + const localEnvPath = path.join(tempDir, '.env.local'); + const realSmokeEnvPath = path.join(tempDir, '.env.real-smoke.local'); + await writeFile( + localEnvPath, + [ + 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://local-original.example/v1', + 'IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=local-original-secret', + 'IMAGE_REAL_SMOKE_GAOREN_BASE_URL=https://local-gaoren.example/v1', + 'IMAGE_REAL_SMOKE_GAOREN_API_KEY=local-gaoren-secret' + ].join('\n') + ); + await writeFile( + realSmokeEnvPath, + [ + 'IMAGE_REAL_SMOKE_GAOREN_BASE_URL=https://real-gaoren.example/v1', + 'IMAGE_REAL_SMOKE_GAOREN_API_KEY=real-gaoren-secret', + 'IMAGE_REAL_SMOKE_SUB2API_BASE_URL=https://real-sub2api.example/v1', + 'IMAGE_REAL_SMOKE_SUB2API_API_KEY=real-sub2api-secret', + 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL=gpt-4.1', + 'IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL=https://real-gpt2image.example/v1', + 'IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY=real-gpt2image-secret', + 'IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL=gpt-5.4' + ].join('\n') + ); + + try { + const statusEnv = readStatusEnvFromFiles( + { + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'shell-original-secret' + }, + [ + { path: localEnvPath, override: false }, + { path: realSmokeEnvPath, override: true } + ] + ); + const status = buildImageUpstreamRealSmokeStatus(statusEnv); + + assert.equal(statusEnv.IMAGE_REAL_SMOKE_ORIGINAL_API_KEY, 'shell-original-secret'); + assert.equal(status.configuration_complete, true); + assert.deepEqual(status.configured_cases, [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.doesNotMatch(JSON.stringify(status), /secret|example\/v1/); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('preserves mismatched env quotes in status env files', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'image-upstream-status-')); + const envPath = path.join(tempDir, '.env.local'); + await writeFile( + envPath, + [ + 'MATCHED_DOUBLE="https://quoted.example/v1"', + "MATCHED_SINGLE='single-secret'", + 'MATCHED_DOUBLE_SPACES=" kept value "', + 'MISMATCHED_LEADING="kept-value', + 'MISMATCHED_TRAILING=kept-value"', + 'MISMATCHED_PAIR="kept-value\'', + 'SINGLE_DOUBLE_QUOTE="', + "SINGLE_SINGLE_QUOTE='" + ].join('\n') + ); + + try { + const statusEnv = readStatusEnvFromFiles({}, [{ path: envPath, override: false }]); + + assert.equal(statusEnv.MATCHED_DOUBLE, 'https://quoted.example/v1'); + assert.equal(statusEnv.MATCHED_SINGLE, 'single-secret'); + assert.equal(statusEnv.MATCHED_DOUBLE_SPACES, ' kept value '); + assert.equal(statusEnv.MISMATCHED_LEADING, '"kept-value'); + assert.equal(statusEnv.MISMATCHED_TRAILING, 'kept-value"'); + assert.equal(statusEnv.MISMATCHED_PAIR, '"kept-value\''); + assert.equal(statusEnv.SINGLE_DOUBLE_QUOTE, '"'); + assert.equal(statusEnv.SINGLE_SINGLE_QUOTE, "'"); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('builds deterministic local deploy compose arguments', () => { + assert.deepEqual(buildDockerComposeArgs(), ['compose', '-f', 'docker-compose.yml', 'up', '-d', '--build']); + assert.deepEqual(buildDockerComposeArgs({ memory: true }), [ + 'compose', + '-f', + 'docker-compose.yml', + '-f', + 'docker-compose.memory.yml', + 'up', + '-d', + '--build' + ]); + }); + + it('uses plain compose progress for diagnosable local deploy output', () => { + assert.deepEqual(buildDockerComposeEnv({ PATH: '/bin', COMPOSE_PROGRESS: 'auto' }), { + PATH: '/bin', + COMPOSE_PROGRESS: 'plain' + }); + }); + + it('fails local memory deploy probes when the overlay did not take effect', () => { + assert.doesNotThrow(() => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' })); + assert.doesNotThrow(() => + assertLocalProbeMatchesMode({ stateBackend: 'memory', imageStorageMode: 'indexeddb' }, { memory: true }) + ); + assert.throws( + () => assertLocalProbeMatchesMode({ stateBackend: 'sqlite', imageStorageMode: 'fs' }, { memory: true }), + /Memory overlay did not take effect/ + ); + }); + + it('routes agent:doctor through the non-billable contract check', () => { + const args = buildAgentDoctorArgs(); + assert.match(args[0], /generate-image\.mjs$/); + assert.deepEqual(args.slice(1), ['--contract-check', '--timeout-ms', '60000', 'contract check']); + }); + + it('preserves raw child output for command consumers', () => { + const result = runCommand(process.execPath, ['-e', 'process.stdout.write(" M README.md\\n")']); + + assert.equal(result.ok, true); + assert.equal(result.stdout, ' M README.md\n'); + }); + + it('parses JSON output after command preamble lines', () => { + assert.deepEqual(parseJsonPayload('hint\n{"ok":true}', 'example'), { ok: true }); + assert.deepEqual(parseJsonPayload('hint\n{"ok":true}\ntrailing log', 'example'), { ok: true }); + assert.deepEqual(parseJsonPayload('[WARN] not json\n{"ok":true}', 'example'), { ok: true }); + assert.throws(() => parseJsonPayload('hint only', 'example'), /example did not return JSON output/); + assert.throws(() => parseJsonPayload('hint\n{"ok":', 'example'), /example returned invalid JSON/); + }); + + it('times out local HTTP probes instead of hanging', async () => { + const server = createServer(() => {}); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + try { + assert.equal(typeof address, 'object'); + await assert.rejects( + fetchJsonWithTimeout(`http://127.0.0.1:${address.port}/api/agent/capabilities`, { timeoutMs: 20 }), + /timed out after 20ms/ + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it('preserves invalid URL fetch errors while building timeout diagnostics', async () => { + await assert.rejects(() => fetchJsonWithTimeout('not a url', { timeoutMs: 20 }), /Failed to parse URL|Invalid URL/i); + }); + + it('includes a response snippet when HTTP probes return non-JSON bodies', async () => { + const server = createServer((request, response) => { + response.writeHead(200, { 'content-type': 'text/html' }); + response.end('login page'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + try { + assert.equal(typeof address, 'object'); + await assert.rejects( + fetchJsonWithTimeout(`http://127.0.0.1:${address.port}/api/agent/capabilities`, { timeoutMs: 1000 }), + /did not return JSON: login page<\/html>/ + ); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it('redacts response snippets from production HTTP probe errors', async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + const server = createServer((request, response) => { + response.writeHead(502, { 'content-type': 'text/plain' }); + response.end('secret upstream body'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + try { + assert.equal(typeof address, 'object'); + await assert.rejects( + fetchJsonWithTimeout(`http://127.0.0.1:${address.port}/api/agent/capabilities`, { timeoutMs: 1000 }), + (error) => { + assert.match(error.message, /failed with HTTP 502/); + assert.doesNotMatch(error.message, /secret upstream body/); + return true; + } + ); + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV; + } else { + process.env.NODE_ENV = originalNodeEnv; + } + await new Promise((resolve) => server.close(resolve)); + } + }); + + it('supports command timeouts for long-running child processes', () => { + const result = runCommand(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { timeoutMs: 20 }); + + assert.equal(result.ok, false); + assert.equal(result.status, null); + assert.match(result.error, /timed out|ETIMEDOUT|SIGTERM/i); + assert.match(pickFailureOutput(result), /error:|signal:/); + }); + + it('includes spawn errors in failure summaries without stdout or stderr', () => { + const summary = pickFailureOutput({ stdout: '', stderr: '', error: 'spawn missing ENOENT', signal: null }); + + assert.equal(summary, 'error: spawn missing ENOENT'); + }); + + it('includes non-zero exit status in failure summaries', () => { + const summary = pickFailureOutput({ stdout: '', stderr: 'bad option', status: 129, signal: null }); + + assert.equal(summary, 'bad option\nstatus: 129'); + }); + + it('keeps local status usable when remote status JSON is malformed', () => { + assert.deepEqual(readRemoteStatusFromResult({ ok: true, stdout: 'not json' }), { + ok: false, + error: 'hf spaces info did not return JSON output.' + }); + }); +}); diff --git a/scripts/deploy-hf-space.mjs b/scripts/deploy-hf-space.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c8c8bc9f3a9c478a38c92c87bd7fd16cbc5e33f0 --- /dev/null +++ b/scripts/deploy-hf-space.mjs @@ -0,0 +1,216 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; + +import { fetchJsonWithTimeout, parseJsonPayload, runCommandStrict } from './command-center-utils.mjs'; +import { assertKnownOptions, HF_SPACE_ID, HF_SPACE_URL, isMainModule } from './hf-space-doctor-utils.mjs'; + +const STATUS_POLL_ATTEMPTS = 40; +const STATUS_POLL_INTERVAL_MS = 10_000; +const PUBLIC_ENDPOINT_TIMEOUT_MS = 10_000; +const HF_CLI_TIMEOUT_MS = 120_000; +export const GIT_ARCHIVE_MAX_BUFFER_BYTES = 256 * 1024 * 1024; + +function parseArgs(argv) { + assertKnownOptions(argv, ['--help', '-h']); + return { + help: argv.includes('--help') || argv.includes('-h') + }; +} + +function printHelp() { + console.log(`Usage: + npm run deploy:hf-space + +Deploys the current clean git HEAD to ${HF_SPACE_ID} with the official hf CLI. + +The script uploads a temporary git archive, waits for the Space to run the new +Space commit, and performs read-only public endpoint checks.`); +} + +function runText(command, args, options = {}) { + return runCommandStrict(command, args, { + input: options.input, + timeoutMs: options.timeoutMs || HF_CLI_TIMEOUT_MS + }).trim(); +} + +function readRepositorySlug() { + const envSlug = process.env.REPO_SLUG?.trim(); + if (envSlug) return envSlug; + try { + return parseRepositorySlug(runText('git', ['remote', 'get-url', 'origin'])); + } catch (error) { + throw new Error( + `Unable to detect repository slug from git origin. Set REPO_SLUG=owner/repo. ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +export function parseRepositorySlug(remoteUrl) { + const text = String(remoteUrl || '').trim(); + const httpsMatch = text.match(/^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/); + if (httpsMatch) return `${httpsMatch[1]}/${httpsMatch[2]}`; + const sshMatch = text.match(/^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/); + if (sshMatch) return `${sshMatch[1]}/${sshMatch[2]}`; + throw new Error('Unable to detect repository slug from git origin URL. Set REPO_SLUG=owner/repo.'); +} + +function runBinary(command, args) { + const result = spawnSync(command, args, { + encoding: 'buffer', + maxBuffer: GIT_ARCHIVE_MAX_BUFFER_BYTES, + stdio: ['ignore', 'pipe', 'pipe'] + }); + if (result.error) throw new Error(`${command} ${args.join(' ')} failed: ${result.error.message}`); + if (result.status !== 0) { + const output = result.stderr.toString('utf8').trim(); + throw new Error(output || `${command} ${args.join(' ')} failed`); + } + return result.stdout; +} + +function assertCleanGitWorktree() { + const status = runText('git', ['status', '--porcelain']); + if (status) { + throw new Error('Refusing to deploy a dirty worktree. Commit or revert local changes first.'); + } +} + +function prepareSourceTree() { + const sourceDir = mkdtempSync(join(tmpdir(), 'gpt-image-hf-space-')); + const archive = runBinary('git', ['archive', '--format=tar', 'HEAD']); + runText('tar', ['-x', '-C', sourceDir], { input: archive }); + return sourceDir; +} + +export function extractUploadCommitSha(output) { + const payload = parseJsonPayload(output, 'hf upload'); + const directSha = [payload.sha, payload.commit, payload.commitSha, payload.commit_sha].find((value) => + /^[0-9a-f]{40}$/.test(String(value || '')) + ); + if (directSha) return directSha; + + const match = String(payload.url || '').match(/\/commit\/([0-9a-f]{40})$/); + if (!match) throw new Error('hf upload output did not include a Space commit SHA or commit URL.'); + return match[1]; +} + +export function buildUploadArgs({ sourceDir, localSha, repoSlug }) { + if (!repoSlug?.trim()) throw new Error('REPO_SLUG is required for deploy metadata.'); + return [ + 'upload', + HF_SPACE_ID, + sourceDir, + '.', + '--repo-type', + 'space', + '--commit-message', + `Deploy ${localSha.slice(0, 7)} to Docker Space`, + '--commit-description', + `Source: ${repoSlug}@${localSha}`, + '--json' + ]; +} + +function uploadSourceTree(sourceDir, localSha) { + const output = runText('hf', buildUploadArgs({ sourceDir, localSha, repoSlug: readRepositorySlug() })); + return extractUploadCommitSha(output); +} + +function readSpaceInfo() { + const output = runText('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json']); + return parseJsonPayload(output, 'hf spaces info'); +} + +async function waitForRunning(spaceCommitSha) { + let lastStage = 'unknown'; + let lastSha = 'unknown'; + for (let attempt = 1; attempt <= STATUS_POLL_ATTEMPTS; attempt += 1) { + const info = readSpaceInfo(); + lastStage = info.runtime?.stage || 'unknown'; + lastSha = info.sha || info.runtime?.raw?.sha || 'unknown'; + console.log(`attempt=${attempt} stage=${lastStage} sha=${lastSha}`); + if (lastStage === 'RUNNING' && lastSha === spaceCommitSha) { + return { stage: lastStage, sha: lastSha }; + } + await delay(STATUS_POLL_INTERVAL_MS); + } + throw new Error(`Space did not reach RUNNING for ${spaceCommitSha}; last stage=${lastStage} sha=${lastSha}`); +} + +async function fetchJson(path) { + return fetchJsonWithTimeout(new URL(path, HF_SPACE_URL), { timeoutMs: PUBLIC_ENDPOINT_TIMEOUT_MS }); +} + +async function verifyPublicEndpoints() { + const authStatus = await fetchJson('/api/auth-status'); + const capabilities = await fetchJson('/api/agent/capabilities'); + const runtime = await fetchJson('/api/runtime-capabilities'); + + if (authStatus.passwordRequired !== true) { + throw new Error('/api/auth-status did not report passwordRequired=true.'); + } + if (capabilities.defaults?.state_backend !== 'memory') { + throw new Error('/api/agent/capabilities did not report state_backend=memory.'); + } + if (capabilities.storage?.image_storage_mode !== 'indexeddb') { + throw new Error('/api/agent/capabilities did not report image_storage_mode=indexeddb.'); + } + return { + passwordRequired: authStatus.passwordRequired, + agentAuth: capabilities.auth, + stateBackend: capabilities.defaults.state_backend, + imageStorageMode: capabilities.storage.image_storage_mode, + streamingBatch: runtime.streamingBatch + }; +} + +async function deploy() { + assertCleanGitWorktree(); + runText('hf', ['auth', 'whoami']); + const localSha = runText('git', ['rev-parse', 'HEAD']); + const sourceDir = prepareSourceTree(); + try { + const spaceCommitSha = uploadSourceTree(sourceDir, localSha); + const runtime = await waitForRunning(spaceCommitSha); + const verification = await verifyPublicEndpoints(); + console.log( + JSON.stringify( + { + ok: true, + spaceId: HF_SPACE_ID, + spaceUrl: HF_SPACE_URL, + localSha, + spaceCommitSha, + runtime, + verification + }, + null, + 2 + ) + ); + } finally { + rmSync(sourceDir, { force: true, recursive: true }); + } +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + await deploy(); +} + +if (isMainModule(import.meta.url, process.argv[1])) { + main().catch((error) => { + console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2)); + process.exit(1); + }); +} diff --git a/scripts/deploy-hf-space.test.mjs b/scripts/deploy-hf-space.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..91fce2c19a0c50297ea950a81e2138bb0b5b2398 --- /dev/null +++ b/scripts/deploy-hf-space.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { GIT_ARCHIVE_MAX_BUFFER_BYTES, buildUploadArgs, extractUploadCommitSha, parseRepositorySlug } from './deploy-hf-space.mjs'; + +describe('HF Space deploy script', () => { + it('extracts the Space commit SHA from hf upload JSON output', () => { + const sha = extractUploadCommitSha( + [ + 'Start hashing 189 files.', + '{"url":"https://huggingface.co/spaces/misonL/gpt-image-playground-customer/commit/32151b6aeaec0e59f14d1aaa87fba160ca8410df"}' + ].join('\n') + ); + + assert.equal(sha, '32151b6aeaec0e59f14d1aaa87fba160ca8410df'); + }); + + it('rejects upload output without a Space commit URL', () => { + assert.throws(() => extractUploadCommitSha('{"url":"https://huggingface.co/spaces/misonL/demo"}'), /commit SHA or commit URL/); + }); + + it('extracts the Space commit SHA from direct hf upload JSON fields', () => { + assert.equal( + extractUploadCommitSha('{"sha":"cccccccccccccccccccccccccccccccccccccccc"}'), + 'cccccccccccccccccccccccccccccccccccccccc' + ); + assert.equal( + extractUploadCommitSha('{"commit_sha":"dddddddddddddddddddddddddddddddddddddddd"}'), + 'dddddddddddddddddddddddddddddddddddddddd' + ); + }); + + it('extracts the Space commit SHA when hf prints warning lines before JSON', () => { + const sha = extractUploadCommitSha( + [ + '[WARN] retrying upload metadata', + '{"url":"https://huggingface.co/spaces/misonL/gpt-image-playground-customer/commit/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}', + 'done' + ].join('\n') + ); + + assert.equal(sha, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + }); + + it('builds upload args without embedding newline characters in commit metadata', () => { + const args = buildUploadArgs({ + sourceDir: '/tmp/source', + localSha: 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', + repoSlug: 'MisonL/gpt-image-playground-customer' + }); + + assert.equal(args.includes('--json'), true); + assert.equal(args[args.indexOf('--commit-message') + 1], 'Deploy bbbbbbb to Docker Space'); + assert.equal( + args[args.indexOf('--commit-description') + 1], + 'Source: MisonL/gpt-image-playground-customer@bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + ); + assert.equal(args.some((arg) => arg.includes('\n')), false); + }); + + it('uses a provided repository slug in upload commit metadata', () => { + const args = buildUploadArgs({ + sourceDir: '/tmp/source', + localSha: 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + repoSlug: 'owner/repo' + }); + + assert.equal(args[args.indexOf('--commit-description') + 1], 'Source: owner/repo@eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'); + }); + + it('parses GitHub repository slugs from common origin URL formats', () => { + assert.equal(parseRepositorySlug('https://github.com/MisonL/gpt-image-playground-customer.git'), 'MisonL/gpt-image-playground-customer'); + assert.equal(parseRepositorySlug('git@github.com:MisonL/gpt-image-playground-customer.git'), 'MisonL/gpt-image-playground-customer'); + assert.throws(() => parseRepositorySlug('not-a-github-url'), /Set REPO_SLUG=owner\/repo/); + }); + + it('requires an explicit repository slug for upload metadata', () => { + assert.throws( + () => + buildUploadArgs({ + sourceDir: '/tmp/source', + localSha: 'ffffffffffffffffffffffffffffffffffffffff' + }), + /REPO_SLUG is required/ + ); + }); + + it('keeps enough buffer for repository archives used by Space uploads', () => { + assert.ok(GIT_ARCHIVE_MAX_BUFFER_BYTES >= 128 * 1024 * 1024); + }); +}); diff --git a/scripts/deploy-local.mjs b/scripts/deploy-local.mjs new file mode 100644 index 0000000000000000000000000000000000000000..5f4a322b1c79465ffe40d7231960668c95c050bf --- /dev/null +++ b/scripts/deploy-local.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { setTimeout as delay } from 'node:timers/promises'; + +import { fetchJsonWithTimeout, isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs'; + +const LOCAL_BASE_URL = 'http://localhost:4783'; +const PROBE_PATHS = ['/api/auth-status', '/api/runtime-capabilities', '/api/agent/capabilities']; +const PROBE_ATTEMPTS = 30; +const PROBE_INTERVAL_MS = 2000; +const PROBE_TIMEOUT_MS = 5000; +const DOCKER_COMPOSE_TIMEOUT_MS = 10 * 60 * 1000; + +export function buildDockerComposeArgs(options = {}) { + const files = ['-f', 'docker-compose.yml']; + if (options.memory) files.push('-f', 'docker-compose.memory.yml'); + return ['compose', ...files, 'up', '-d', '--build']; +} + +export function buildDockerComposeEnv(env = process.env) { + return { ...env, COMPOSE_PROGRESS: 'plain' }; +} + +function parseArgs(argv) { + const unknown = argv.find((arg) => !['--help', '-h', '--memory', '--skip-probe'].includes(arg)); + if (unknown) throw new Error(`Unknown option: ${unknown}`); + return { + help: argv.includes('--help') || argv.includes('-h'), + memory: argv.includes('--memory'), + skipProbe: argv.includes('--skip-probe') + }; +} + +function printHelp() { + console.log(`Usage: + npm run deploy:local + npm run deploy:local -- --memory + +Options: + --memory Use docker-compose.memory.yml overlay for HF Space-like memory mode. + --skip-probe Rebuild and start the container without HTTP endpoint probes. + --help Show this help.`); +} + +async function fetchJson(path) { + return fetchJsonWithTimeout(new URL(path, LOCAL_BASE_URL), { timeoutMs: PROBE_TIMEOUT_MS }); +} + +async function waitForLocalEndpoints() { + let lastError = ''; + for (let attempt = 1; attempt <= PROBE_ATTEMPTS; attempt += 1) { + try { + const responses = {}; + for (const path of PROBE_PATHS) responses[path] = await fetchJson(path); + return { + attempts: attempt, + baseUrl: LOCAL_BASE_URL, + authRequired: responses['/api/auth-status'].passwordRequired, + stateBackend: responses['/api/agent/capabilities'].defaults?.state_backend, + imageStorageMode: responses['/api/agent/capabilities'].storage?.image_storage_mode, + streamingBatch: responses['/api/runtime-capabilities'].streamingBatch + }; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + await delay(PROBE_INTERVAL_MS); + } + } + throw new Error(`Local container did not pass HTTP probes: ${lastError}`); +} + +export function assertLocalProbeMatchesMode(probe, options = {}) { + if (!options.memory) return; + const mismatches = []; + if (probe.stateBackend !== 'memory') mismatches.push(`stateBackend=${probe.stateBackend ?? ''} expected memory`); + if (probe.imageStorageMode !== 'indexeddb') { + mismatches.push(`imageStorageMode=${probe.imageStorageMode ?? ''} expected indexeddb`); + } + if (mismatches.length) throw new Error(`Memory overlay did not take effect: ${mismatches.join(', ')}.`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const docker = runCommand('docker', buildDockerComposeArgs(options), { + env: buildDockerComposeEnv(), + timeoutMs: DOCKER_COMPOSE_TIMEOUT_MS + }); + if (!docker.ok) { + printJson({ ok: false, phase: 'docker-compose', output: pickFailureOutput(docker) }); + process.exit(1); + } + + if (options.skipProbe) { + printJson({ ok: true, phase: 'docker-compose', probe: 'skipped' }); + return; + } + + const probe = await waitForLocalEndpoints(); + assertLocalProbeMatchesMode(probe, options); + printJson({ ok: true, phase: 'ready', probe }); +} + +if (isMainModule(import.meta.url, process.argv[1])) { + main().catch((error) => { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); + }); +} diff --git a/scripts/doctor-hf-space.mjs b/scripts/doctor-hf-space.mjs index 8be964c0586297a51c7bccee61b1a7e1440fd924..5f7713c77f70aef2854547c8fec69d06fe5c51a3 100644 --- a/scripts/doctor-hf-space.mjs +++ b/scripts/doctor-hf-space.mjs @@ -1,34 +1,32 @@ #!/usr/bin/env node -import { existsSync, statSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { assertKnownOptions, buildNextActions, classifyRequiredAndRecommendedNames, - DEFAULT_ACCESS_FILE, + HF_SPACE_ID, + HF_SPACE_URL, + getJsonKeyValues, getJsonNames, isMainModule, - missingKeys, - parseAccessFile, - readEnvValue, - readOptionValue, - runCommand, - validateSpaceId, + runDoctorCommand, validateSpaceUrl } from './hf-space-doctor-utils.mjs'; const MIN_NODE_MAJOR = 20; -const REQUIRED_ACCESS_KEYS = ['HF_SPACE_ID', 'HF_SPACE_URL', 'HF_SPACE_SECRET_KEYS', 'APP_PASSWORD', 'AGENT_API_TOKEN']; -const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD']; const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE']; const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL']; +const REQUIRED_SPACE_VARIABLE_VALUES = new Map([ + ['AGENT_STATE_BACKEND', 'memory'], + ['NEXT_PUBLIC_IMAGE_STORAGE_MODE', 'indexeddb'] +]); const REQUIRED_SPACE_SECRETS = ['APP_PASSWORD', 'AGENT_API_TOKEN']; const OPTIONAL_GENERATION_SECRETS = ['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS']; function parseArgs(argv) { - assertKnownOptions(argv, ['--access-file', '--help', '-h', '--skip-remote']); + assertKnownOptions(argv, ['--help', '-h', '--skip-remote']); return { - accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE, help: argv.includes('--help') || argv.includes('-h'), skipRemote: argv.includes('--skip-remote') }; @@ -39,12 +37,8 @@ function printHelp() { npm run doctor:hf-space Options: - --access-file Override the access file path. --skip-remote Skip read-only Hugging Face remote checks. - --help Show this help. - -Environment overrides: - HF_SPACE_ACCESS_FILE`); + --help Show this help.`); } function addCheck(checks, status, name, message, details = {}) { @@ -61,7 +55,7 @@ function checkNode(checks) { } function checkCommand(checks, name, command, args, failureAction) { - const result = runCommand(command, args); + const result = runDoctorCommand(command, args); if (result.ok) { addCheck(checks, 'pass', name, `${command} is available.`, { version: result.stdout.split(/\r?\n/)[0] }); return true; @@ -70,127 +64,44 @@ function checkCommand(checks, name, command, args, failureAction) { return false; } -function checkAccessFile(checks, accessFile) { - if (!existsSync(accessFile)) { - addCheck(checks, 'fail', 'access-file', `Access file is missing: ${accessFile}`, { - action: 'Run npm run init-access:hf-space -- --space-id / --space-url https://-.hf.space' - }); - return undefined; - } - - let values; - try { - values = parseAccessFile(accessFile); - } catch (error) { - addCheck(checks, 'fail', 'access-file', 'Access file cannot be read.', { - error: error instanceof Error ? error.message : String(error) - }); - return undefined; - } - - addCheck(checks, 'pass', 'access-file', `Access file exists: ${accessFile}`); - - if (process.platform !== 'win32') { - const mode = statSync(accessFile).mode & 0o777; - if ((mode & 0o077) === 0) { - addCheck(checks, 'pass', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}.`); - } else { - addCheck(checks, 'fail', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}; expected 600.`, { - action: `chmod 600 ${accessFile}` - }); - } - } - - const missing = missingKeys(REQUIRED_ACCESS_KEYS, values); - if (missing.length) { - addCheck(checks, 'fail', 'access-file-keys', `Access file is missing required keys: ${missing.join(', ')}.`); - } else { - addCheck(checks, 'pass', 'access-file-keys', 'Access file contains all required non-empty keys.'); - } - - const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => values.has(key)); - if (forbidden.length) { - addCheck(checks, 'fail', 'access-file-forbidden-keys', `Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`); - } else { - addCheck(checks, 'pass', 'access-file-forbidden-keys', 'Access file does not contain Hugging Face account credentials.'); - } - - validateAccessValues(checks, values); - return values; -} - -function validateAccessValues(checks, values) { - const spaceId = values.get('HF_SPACE_ID')?.trim(); - const spaceIdError = validateSpaceId(spaceId); - if (!spaceIdError) { - addCheck(checks, 'pass', 'space-id', 'HF_SPACE_ID has namespace/space format.'); - } else if (spaceId) { - addCheck(checks, 'fail', 'space-id', spaceIdError); - } - - const spaceUrl = values.get('HF_SPACE_URL')?.trim(); - if (spaceUrl) { - const spaceUrlError = validateSpaceUrl(spaceUrl); - if (spaceUrlError) { - addCheck(checks, 'fail', 'space-url', spaceUrlError); - } else { - addCheck(checks, 'pass', 'space-url', 'HF_SPACE_URL looks like a Hugging Face Space URL.'); - } - } - - const appPassword = values.get('APP_PASSWORD') || ''; - const agentToken = values.get('AGENT_API_TOKEN') || ''; - if (appPassword.length >= 16 && agentToken.length >= 24) { - addCheck( - checks, - 'pass', - 'generated-secrets', - 'APP_PASSWORD access code and AGENT_API_TOKEN meet the minimum length checks.' - ); - } else { - addCheck( - checks, - 'fail', - 'generated-secrets', - 'APP_PASSWORD access code must be at least 16 chars and AGENT_API_TOKEN at least 24 chars.' - ); +function checkConfiguredTarget(checks) { + const spaceUrlError = validateSpaceUrl(HF_SPACE_URL); + if (spaceUrlError) { + addCheck(checks, 'fail', 'space-target', spaceUrlError); + return; } + addCheck(checks, 'pass', 'space-target', `Using fixed Space target ${HF_SPACE_ID}.`, { spaceUrl: HF_SPACE_URL }); } -function checkRemote(checks, values, skipRemote, hfAvailable, hfAuthenticated) { +function checkRemote(checks, skipRemote, hfAvailable, hfAuthenticated) { if (skipRemote) { addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.'); return; } - if (!hfAvailable || !hfAuthenticated || !values) { - addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI, hf auth login, and a valid access file.'); + if (!hfAvailable || !hfAuthenticated) { + addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI and hf auth login.'); return; } - const spaceId = values.get('HF_SPACE_ID')?.trim(); - if (!spaceId) { - addCheck(checks, 'skip', 'remote-space', 'Remote checks require HF_SPACE_ID in the access file.'); - return; - } - const info = runCommand('hf', ['spaces', 'info', spaceId, '--format', 'json']); + const info = runDoctorCommand('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json']); if (!info.ok) { - addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${spaceId}.`, { error: info.error }); + addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${HF_SPACE_ID}.`, { error: info.error }); return; } - addCheck(checks, 'pass', 'remote-space', `Space ${spaceId} is accessible.`); + addCheck(checks, 'pass', 'remote-space', `Space ${HF_SPACE_ID} is accessible.`); checkRemoteNames( checks, - spaceId, + HF_SPACE_ID, 'remote-variables', - ['spaces', 'variables', 'list', spaceId, '--json'], + ['spaces', 'variables', 'list', HF_SPACE_ID, '--json'], REQUIRED_SPACE_VARIABLES, RECOMMENDED_SPACE_VARIABLES ); - checkRemoteSecrets(checks, spaceId); + checkRemoteSecrets(checks, HF_SPACE_ID); } function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) { - const result = runCommand('hf', args); + const result = runDoctorCommand('hf', args); if (!result.ok) { addCheck(checks, 'warn', name, `Cannot list ${name} for ${spaceId}.`, { error: result.error }); return; @@ -210,6 +121,7 @@ function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommende if (missingRecommended.length) { addCheck(checks, 'warn', name, `${name} missing recommended names: ${missingRecommended.join(', ')}.`); } + checkRemoteVariableValues(checks, result.stdout); } catch (error) { addCheck(checks, 'warn', name, `Cannot parse ${name} JSON output.`, { error: error instanceof Error ? error.message : String(error) @@ -217,8 +129,22 @@ function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommende } } +function checkRemoteVariableValues(checks, jsonText) { + const values = getJsonKeyValues(jsonText); + const mismatches = []; + for (const [key, expected] of REQUIRED_SPACE_VARIABLE_VALUES) { + const actual = values.get(key); + if (actual !== expected) mismatches.push(`${key}=${actual ?? ''} expected ${expected}`); + } + if (mismatches.length) { + addCheck(checks, 'fail', 'remote-variable-values', `Remote variable values are not Space-free compatible: ${mismatches.join(', ')}.`); + return; + } + addCheck(checks, 'pass', 'remote-variable-values', 'Remote variable values match the Space-free runtime contract.'); +} + function checkRemoteSecrets(checks, spaceId) { - const result = runCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']); + const result = runDoctorCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']); if (!result.ok) { addCheck(checks, 'warn', 'remote-secrets', `Cannot list remote secrets for ${spaceId}.`, { error: result.error }); return; @@ -255,7 +181,7 @@ function main() { checkNode(checks); checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.'); const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.'); - const hfAuth = hfAvailable ? runCommand('hf', ['auth', 'whoami']) : { ok: false }; + const hfAuth = hfAvailable ? runDoctorCommand('hf', ['auth', 'whoami']) : { ok: false }; if (hfAvailable && hfAuth.ok) { addCheck(checks, 'pass', 'hf-auth', 'hf CLI is authenticated.'); } else if (hfAvailable) { @@ -270,19 +196,19 @@ function main() { addCheck(checks, 'warn', 'node-modules', 'node_modules is missing; build, lint, test, and smoke commands require npm install.'); } checkCommand(checks, 'git', 'git', ['--version'], 'git is missing; install git before cloning or pushing Space repos.'); - const docker = runCommand('docker', ['version', '--format', '{{.Server.Version}}']); + const docker = runDoctorCommand('docker', ['version', '--format', '{{.Server.Version}}']); if (docker.ok) { addCheck(checks, 'pass', 'docker', 'docker is available.', { version: docker.stdout.split(/\r?\n/)[0] }); } else { addCheck(checks, 'warn', 'docker', 'Docker is unavailable; npm run smoke:hf-space will not work.', { error: docker.error }); - const dockerCli = runCommand('docker', ['--version']); + const dockerCli = runDoctorCommand('docker', ['--version']); if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.'); } - const values = checkAccessFile(checks, options.accessFile); - checkRemote(checks, values, options.skipRemote, hfAvailable, Boolean(hfAuth.ok)); + checkConfiguredTarget(checks); + checkRemote(checks, options.skipRemote, hfAvailable, Boolean(hfAuth.ok)); const failed = checks.some((check) => check.status === 'fail'); console.log(JSON.stringify({ ok: !failed, checks, nextActions: buildNextActions(checks) }, null, 2)); diff --git a/scripts/doctor.mjs b/scripts/doctor.mjs new file mode 100644 index 0000000000000000000000000000000000000000..977ea58c63adbcc71f595c230ccccccbacd364e0 --- /dev/null +++ b/scripts/doctor.mjs @@ -0,0 +1,55 @@ +#!/usr/bin/env node + +import { fileURLToPath } from 'node:url'; + +import { isMainModule, parseJsonPayload, printJson, runCommand } from './command-center-utils.mjs'; + +const HF_DOCTOR_SCRIPT = fileURLToPath(new URL('./doctor-hf-space.mjs', import.meta.url)); +const DOCTOR_TIMEOUT_MS = 120_000; + +function parseArgs(argv) { + const unknown = argv.find((arg) => !['--help', '-h', '--skip-remote'].includes(arg)); + if (unknown) throw new Error(`Unknown option: ${unknown}`); + return { + help: argv.includes('--help') || argv.includes('-h'), + skipRemote: argv.includes('--skip-remote') + }; +} + +function printHelp() { + console.log(`Usage: + npm run doctor + npm run doctor -- --skip-remote + +Options: + --skip-remote Skip read-only Hugging Face remote checks. + --help Show this help.`); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const args = [HF_DOCTOR_SCRIPT, ...(options.skipRemote ? ['--skip-remote'] : [])]; + const result = runCommand(process.execPath, args, { timeoutMs: DOCTOR_TIMEOUT_MS }); + const child = result.stdout ? parseJsonPayload(result.stdout, 'doctor:hf-space') : {}; + const ok = result.ok && child.ok !== false; + printJson({ + ok, + profile: 'local-and-hf-space', + checks: child.checks || [], + nextActions: child.nextActions || [], + ...(ok ? {} : { error: child.error || result.stderr || result.error || 'doctor failed' }) + }); + if (!ok) process.exit(1); +} + +try { + if (isMainModule(import.meta.url, process.argv[1])) main(); +} catch (error) { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +} diff --git a/scripts/env-utils.mjs b/scripts/env-utils.mjs new file mode 100644 index 0000000000000000000000000000000000000000..89afd9967919ff3d3ca9910f5169e9163ba69277 --- /dev/null +++ b/scripts/env-utils.mjs @@ -0,0 +1,15 @@ +export function readPositiveIntegerEnv(name, defaultValue, minValue = 1) { + const rawValue = process.env[name]?.trim(); + if (!rawValue) return defaultValue; + if (!/^\d+$/.test(rawValue)) { + throw new Error(`${name} must be a positive integer formatted as digits`); + } + const value = Number.parseInt(rawValue, 10); + if (!Number.isSafeInteger(value)) { + throw new Error(`${name} must be a safe integer <= ${Number.MAX_SAFE_INTEGER}`); + } + if (value < minValue) { + throw new Error(`${name} must be a positive integer greater than or equal to ${minValue}`); + } + return value; +} diff --git a/scripts/env-utils.test.mjs b/scripts/env-utils.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..01c240f3446da9a9ce08002c38a2775e922c2b41 --- /dev/null +++ b/scripts/env-utils.test.mjs @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +import { readPositiveIntegerEnv } from './env-utils.mjs'; + +const TEST_ENV_NAME = 'GPT_IMAGE_PLAYGROUND_TEST_INTEGER'; + +afterEach(() => { + delete process.env[TEST_ENV_NAME]; +}); + +describe('Environment utilities', () => { + it('returns the default value when the env var is unset', () => { + assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30), 30); + }); + + it('returns valid integer env values', () => { + process.env[TEST_ENV_NAME] = '42'; + + assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30), 42); + }); + + it('accepts values exactly equal to the configured minimum', () => { + process.env[TEST_ENV_NAME] = '1000'; + + assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30, 1000), 1000); + }); + + it('accepts zero when the configured minimum is zero', () => { + process.env[TEST_ENV_NAME] = '0'; + + assert.equal(readPositiveIntegerEnv(TEST_ENV_NAME, 30, 0), 0); + }); + + it('rejects non-digit values before parsing', () => { + process.env[TEST_ENV_NAME] = '12ms'; + + assert.throws(() => readPositiveIntegerEnv(TEST_ENV_NAME, 30), /formatted as digits/); + }); + + it('rejects values below the configured minimum', () => { + process.env[TEST_ENV_NAME] = '999'; + + assert.throws(() => readPositiveIntegerEnv(TEST_ENV_NAME, 30, 1000), /greater than or equal to 1000/); + }); + + it('rejects integers that exceed JavaScript safe integer range', () => { + process.env[TEST_ENV_NAME] = String(Number.MAX_SAFE_INTEGER + 1); + + assert.throws(() => readPositiveIntegerEnv(TEST_ENV_NAME, 30), /safe integer/); + }); +}); diff --git a/scripts/hf-space-doctor-utils.mjs b/scripts/hf-space-doctor-utils.mjs index ab471f480f9678929d3c095adf04f27b6f9b9e7e..6f331e1cba63b676ff1dfedce9865d59fcb7f247 100644 --- a/scripts/hf-space-doctor-utils.mjs +++ b/scripts/hf-space-doctor-utils.mjs @@ -1,9 +1,10 @@ import { spawnSync } from 'node:child_process'; -import { readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +import { resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; -export const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt'); +export const HF_SPACE_ID = 'misonL/gpt-image-playground-customer'; +export const HF_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space'; +const DOCTOR_COMMAND_TIMEOUT_MS = 30_000; export function readEnvValue(name) { return process.env[name]?.trim() || undefined; @@ -34,12 +35,16 @@ export function assertKnownOptions(argv, knownOptions) { } } -export function runCommand(command, args = []) { +export function runDoctorCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'] + stdio: ['ignore', 'pipe', 'pipe'], + timeout: options.timeoutMs || DOCTOR_COMMAND_TIMEOUT_MS }); - if (result.error) return { ok: false, error: result.error.message }; + if (result.error) { + const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); + return { ok: false, error: output ? `${result.error.message}\n${output}` : result.error.message }; + } if (result.status !== 0) { const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim(); return { ok: false, error: output || `${command} ${args.join(' ')} failed` }; @@ -47,20 +52,6 @@ export function runCommand(command, args = []) { return { ok: true, stdout: result.stdout.trim() }; } -export function parseAccessFile(accessFile) { - const values = new Map(); - const text = readFileSync(accessFile, 'utf8'); - for (const rawLine of text.split(/\r?\n/)) { - if (!rawLine || rawLine.startsWith('#')) continue; - const separatorIndex = rawLine.indexOf('='); - if (separatorIndex <= 0) continue; - const key = rawLine.slice(0, separatorIndex).trim(); - const value = rawLine.slice(separatorIndex + 1); - if (key) values.set(key, value); - } - return values; -} - export function getJsonNames(text) { const parsed = JSON.parse(extractJsonPayload(text)); const names = new Set(); @@ -78,6 +69,23 @@ export function getJsonNames(text) { return names; } +export function getJsonKeyValues(text) { + const parsed = JSON.parse(extractJsonPayload(text)); + const values = new Map(); + const visit = (value) => { + if (Array.isArray(value)) { + for (const item of value) visit(item); + return; + } + if (!value || typeof value !== 'object') return; + if (typeof value.key === 'string' && typeof value.value === 'string') { + values.set(value.key, value.value); + } + }; + visit(parsed); + return values; +} + function extractJsonPayload(text) { const trimmedText = text.trim(); if (!trimmedText) return '[]'; @@ -108,16 +116,9 @@ const NEXT_ACTIONS = new Map([ ['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'], ['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'], ['node-modules', 'Run npm install.'], - [ - 'access-file-keys', - 'Regenerate or update the access file with npm run init-access:hf-space -- --space-id / --space-url https://-.hf.space' - ], - [ - 'generated-secrets', - 'Regenerate weak or blank project secrets with npm run init-access:hf-space -- --space-id / --space-url https://-.hf.space --force' - ], - ['remote-variables', 'Configure the required and recommended Space Variables in Hugging Face Settings before syncing secrets.'], - ['remote-secrets', 'Run npm run sync-secret:hf-space after hf auth login succeeds.'], + ['remote-variables', 'Configure the required and recommended Space Variables with hf spaces variables add.'], + ['remote-variable-values', 'Set required Space Variable values with hf spaces variables add.'], + ['remote-secrets', 'Configure required Space Secrets with hf spaces secrets add.'], ['remote-generation-secret', 'Configure OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS in Space Secrets before real image generation.'] ]); @@ -133,8 +134,20 @@ export function buildNextActions(checks) { } export function validateSpaceId(spaceId) { - if (!spaceId?.trim()) return 'HF Space id is required.'; - if (!/^[^/\s]+\/[^/\s]+$/.test(spaceId)) return 'HF Space id must use namespace/space format.'; + const text = spaceId?.trim(); + if (!text) return 'HF Space id is required.'; + const parts = text.split('/'); + if (parts.length < 1 || parts.length > 2 || parts.some((part) => part.length === 0)) { + return 'HF Space id must use space_name or namespace/space_name format with 1-96 characters per part.'; + } + for (const part of parts) { + if (part.length > 96 || !/^[A-Za-z0-9._-]+$/.test(part)) { + return 'HF Space id must use space_name or namespace/space_name format with 1-96 characters per part.'; + } + if (part.startsWith('.') || part.includes('..') || part.includes('--') || part.endsWith('.git')) { + return 'HF Space id parts cannot start with ".", contain ".." or "--", or end with ".git".'; + } + } return undefined; } diff --git a/scripts/hf-space-doctor-utils.test.mjs b/scripts/hf-space-doctor-utils.test.mjs index 624b11239f3bfe2de194dd37924491497ccc4066..bb20ae1f6571f40f184820052e24ce52d3f2026c 100644 --- a/scripts/hf-space-doctor-utils.test.mjs +++ b/scripts/hf-space-doctor-utils.test.mjs @@ -8,9 +8,11 @@ import { assertSpaceTargetConfig, buildNextActions, classifyRequiredAndRecommendedNames, + getJsonKeyValues, getJsonNames, isMainModule, readOptionValue, + runDoctorCommand, validateSpaceId, validateSpaceUrl } from './hf-space-doctor-utils.mjs'; @@ -38,6 +40,20 @@ describe('HF Space doctor utilities', () => { assert.deepEqual([...names], ['OPENAI_API_KEY']); }); + it('parses hf CLI variable values after hint lines', () => { + const values = getJsonKeyValues( + [ + 'Hint: Use `hf spaces variables add user/space -e KEY=VALUE` to add variables.', + '[{"key":"AGENT_STATE_BACKEND","value":"memory"},{"key":"NEXT_PUBLIC_IMAGE_STORAGE_MODE","value":"indexeddb"}]' + ].join('\n') + ); + + assert.deepEqual([...values], [ + ['AGENT_STATE_BACKEND', 'memory'], + ['NEXT_PUBLIC_IMAGE_STORAGE_MODE', 'indexeddb'] + ]); + }); + it('keeps auth remediation broad enough for network and token failures', () => { const actions = buildNextActions([ { @@ -76,8 +92,13 @@ describe('HF Space doctor utilities', () => { it('validates Hugging Face Space target config consistently', () => { assert.equal(validateSpaceId('example/demo'), undefined); + assert.equal(validateSpaceId('demo'), undefined); assert.equal(validateSpaceUrl('https://example-demo.hf.space'), undefined); - assert.match(validateSpaceId('bad'), /namespace\/space/); + assert.match(validateSpaceId('a/b/c'), /space_name or namespace\/space_name/); + assert.match(validateSpaceId('.bad/demo'), /cannot start/); + assert.match(validateSpaceId('bad--name/demo'), /cannot start/); + assert.match(validateSpaceId('bad.git/demo'), /cannot start/); + assert.match(validateSpaceId(`${'a'.repeat(97)}/demo`), /1-96 characters/); assert.match(validateSpaceUrl('https://example.com'), /\.hf\.space/); assert.match(validateSpaceUrl('https://user:pass@example-demo.hf.space'), /plain Space origin/); assert.match(validateSpaceUrl('https://example-demo.hf.space/share/abc'), /plain Space origin/); @@ -93,9 +114,15 @@ describe('HF Space doctor utilities', () => { }); it('rejects unknown CLI options and blank inline option values', () => { - assert.doesNotThrow(() => assertKnownOptions(['--access-file', 'tmp.txt'], ['--access-file'])); - assert.doesNotThrow(() => assertKnownOptions(['--access-file=tmp.txt'], ['--access-file'])); - assert.throws(() => assertKnownOptions(['--unknown'], ['--access-file']), /Unknown option/); + assert.doesNotThrow(() => assertKnownOptions(['--skip-remote'], ['--skip-remote'])); + assert.throws(() => assertKnownOptions(['--unknown'], ['--skip-remote']), /Unknown option/); assert.throws(() => readOptionValue(['--space-id='], '--space-id'), /requires a value/); }); + + it('times out direct doctor commands instead of hanging', () => { + const result = runDoctorCommand(process.execPath, ['-e', 'setTimeout(() => {}, 1000)'], { timeoutMs: 100 }); + + assert.equal(result.ok, false); + assert.match(result.error, /timed out|ETIMEDOUT|SIGTERM/i); + }); }); diff --git a/scripts/keepalive-hf-space.mjs b/scripts/keepalive-hf-space.mjs index f0f946cde621dcc03661f563a630738268cdcc50..98ef67c2093a18f6f34598ba3ac2f77bfae12735 100644 --- a/scripts/keepalive-hf-space.mjs +++ b/scripts/keepalive-hf-space.mjs @@ -1,25 +1,11 @@ #!/usr/bin/env node +import { readPositiveIntegerEnv } from './env-utils.mjs'; import { validateSpaceUrl } from './hf-space-doctor-utils.mjs'; const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space'; const DEFAULT_KEEPALIVE_PATH = '/api/auth-status'; const DEFAULT_TIMEOUT_MS = 30_000; -const MIN_TIMEOUT_MS = 1_000; - -function readPositiveIntegerEnv(name, fallback) { - const rawValue = process.env[name]?.trim(); - if (!rawValue) return fallback; - if (!/^\d+$/.test(rawValue)) { - throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`); - } - const value = Number(rawValue); - if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) { - throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`); - } - return value; -} - function normalizeUrl(rawUrl, path) { const urlError = validateSpaceUrl(rawUrl); if (urlError) { @@ -51,7 +37,7 @@ async function readJsonResponse(response) { async function pingKeepaliveEndpoint() { const spaceUrl = process.env.HF_SPACE_KEEPALIVE_URL?.trim() || DEFAULT_SPACE_URL; const path = process.env.HF_SPACE_KEEPALIVE_PATH?.trim() || DEFAULT_KEEPALIVE_PATH; - const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS); + const timeoutMs = readPositiveIntegerEnv('HF_SPACE_KEEPALIVE_TIMEOUT_MS', DEFAULT_TIMEOUT_MS, 1_000); const expectedPasswordRequired = readExpectedPasswordRequired(); const url = normalizeUrl(spaceUrl, path); const controller = new AbortController(); diff --git a/scripts/local-image-upstream-fixture.mjs b/scripts/local-image-upstream-fixture.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9e1efcf3015d06148494c66e1f7b9602d5cab94c --- /dev/null +++ b/scripts/local-image-upstream-fixture.mjs @@ -0,0 +1,227 @@ +#!/usr/bin/env node + +import http from 'node:http'; +import { pathToFileURL } from 'node:url'; + +export const FIXTURE_IMAGE_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; +export const MAX_JSON_BODY_BYTES = 1024 * 1024; + +class PayloadTooLargeError extends Error { + constructor() { + super('Request body too large'); + this.name = 'PayloadTooLargeError'; + this.status = 413; + } +} + +function readJsonBody(request) { + return new Promise((resolve, reject) => { + let raw = ''; + let byteLength = 0; + let tooLarge = false; + let settled = false; + const cleanup = () => { + request.off('data', onData); + request.off('end', onEnd); + request.off('error', onError); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const finish = (value) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const onData = (chunk) => { + byteLength += Buffer.byteLength(chunk, 'utf8'); + if (byteLength > MAX_JSON_BODY_BYTES) { + tooLarge = true; + return; + } + if (tooLarge) return; + raw += chunk; + }; + const onEnd = () => { + if (tooLarge) { + fail(new PayloadTooLargeError()); + return; + } + if (!raw.trim()) { + finish({}); + return; + } + try { + finish(JSON.parse(raw)); + } catch (error) { + fail(error); + } + }; + const onError = (error) => fail(error); + request.setEncoding('utf8'); + request.on('data', onData); + request.on('end', onEnd); + request.on('error', onError); + }); +} + +function sendJson(response, status, body) { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(body)); +} + +function writeSse(response, event, payload) { + if (event) response.write(`event: ${event}\n`); + response.write(`data: ${JSON.stringify(payload)}\n\n`); +} + +function sendImagesStream(response) { + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }); + response.write(': keepalive\n\n'); + writeSse(response, 'image_generation.partial_image', { + type: 'image_generation.partial_image', + partial_image_index: 0, + b64_json: FIXTURE_IMAGE_BASE64 + }); + writeSse(response, 'image_generation.completed', { + type: 'image_generation.completed', + b64_json: FIXTURE_IMAGE_BASE64, + output_format: 'png', + size: '1024x1024', + quality: 'low' + }); + response.end('data: [DONE]\n\n'); +} + +function sendResponsesStream(response) { + response.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive' + }); + writeSse(response, 'response.image_generation_call.partial_image', { + type: 'response.image_generation_call.partial_image', + partial_image_b64: FIXTURE_IMAGE_BASE64, + partial_image_index: 0 + }); + writeSse(response, 'response.output_item.done', { + type: 'response.output_item.done', + item: { + id: 'ig_fixture', + type: 'image_generation_call', + status: 'completed', + result: FIXTURE_IMAGE_BASE64 + } + }); + writeSse(response, 'response.completed', { + type: 'response.completed', + response: { + output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }] + } + }); + response.end('data: [DONE]\n\n'); +} + +function imagesResponse(body) { + const created = Math.floor(Date.now() / 1000); + return { + created, + data: [{ b64_json: FIXTURE_IMAGE_BASE64 }], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2 + }, + fixture_request: { + model: typeof body.model === 'string' ? body.model : undefined, + stream: body.stream === true + } + }; +} + +function responsesJsonResponse(body) { + return { + id: 'resp_fixture', + object: 'response', + model: typeof body.model === 'string' ? body.model : 'gpt-5.4', + output: [{ id: 'ig_fixture', type: 'image_generation_call', status: 'completed', result: FIXTURE_IMAGE_BASE64 }], + usage: { + input_tokens: 1, + output_tokens: 1, + total_tokens: 2 + } + }; +} + +function modelsResponse() { + return { + object: 'list', + data: [ + { id: 'gpt-image-2', object: 'model', owned_by: 'fixture' }, + { id: 'gpt-5.4', object: 'model', owned_by: 'fixture' } + ] + }; +} + +async function handleRequest(request, response) { + const url = new URL(request.url || '/', 'http://fixture.local'); + if (request.method === 'GET' && url.pathname === '/health') { + sendJson(response, 200, { ok: true }); + return; + } + if (request.method === 'GET' && url.pathname === '/v1/models') { + sendJson(response, 200, modelsResponse()); + return; + } + if (request.method === 'POST' && url.pathname === '/v1/images/generations') { + const body = await readJsonBody(request); + if (body.stream === true) { + sendImagesStream(response); + return; + } + sendJson(response, 200, imagesResponse(body)); + return; + } + if (request.method === 'POST' && url.pathname === '/v1/responses') { + const body = await readJsonBody(request); + if (body.stream === true) { + sendResponsesStream(response); + return; + } + sendJson(response, 200, responsesJsonResponse(body)); + return; + } + sendJson(response, 404, { error: { message: `No fixture route for ${request.method} ${url.pathname}` } }); +} + +export function createFixtureServer() { + return http.createServer((request, response) => { + handleRequest(request, response).catch((error) => { + const status = error instanceof PayloadTooLargeError ? error.status : 500; + sendJson(response, status, { error: { message: error instanceof Error ? error.message : String(error) } }); + }); + }); +} + +export function startFixtureServer(port = Number(process.env.PORT || 19080), host = process.env.HOST || '127.0.0.1') { + const server = createFixtureServer(); + server.listen(port, host, () => { + const address = server.address(); + const resolvedPort = typeof address === 'object' && address ? address.port : port; + console.log(`local image upstream fixture listening on http://${host}:${resolvedPort}`); + }); + return server; +} + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + startFixtureServer(); +} diff --git a/scripts/local-image-upstream-fixture.test.mjs b/scripts/local-image-upstream-fixture.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0dc6c201ddb9471ea670f020ed1b3eb853cd1eb9 --- /dev/null +++ b/scripts/local-image-upstream-fixture.test.mjs @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { FIXTURE_IMAGE_BASE64, MAX_JSON_BODY_BYTES, createFixtureServer } from './local-image-upstream-fixture.mjs'; + +describe('local image upstream fixture', () => { + it('serves Images API JSON responses', async () => { + const fixture = await startFixture(); + try { + const response = await fetch(`${fixture.baseUrl}/v1/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'fixture json smoke', stream: false }) + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^application\/json\b/); + const body = await response.json(); + assert.equal(body.data[0].b64_json, FIXTURE_IMAGE_BASE64); + assert.equal(body.fixture_request.model, 'gpt-image-2'); + assert.equal(body.fixture_request.stream, false); + } finally { + await fixture.close(); + } + }); + + it('serves Images API SSE responses', async () => { + const fixture = await startFixture(); + try { + const response = await fetch(`${fixture.baseUrl}/v1/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-image-2', prompt: 'fixture sse smoke', stream: true }) + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^text\/event-stream\b/); + const events = readSseEvents(await response.text()); + assert.equal(events[0].comment, 'keepalive'); + assert.deepEqual( + events.filter((event) => event.event).map((event) => event.event), + ['image_generation.partial_image', 'image_generation.completed'] + ); + assert.equal(events[1].data.type, 'image_generation.partial_image'); + assert.equal(events[1].data.b64_json, FIXTURE_IMAGE_BASE64); + assert.equal(events[2].data.type, 'image_generation.completed'); + assert.equal(events[2].data.b64_json, FIXTURE_IMAGE_BASE64); + assert.equal(events[3].done, true); + } finally { + await fixture.close(); + } + }); + + it('serves Responses API JSON image outputs', async () => { + const fixture = await startFixture(); + try { + const response = await fetch(`${fixture.baseUrl}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.4', + input: 'fixture responses json smoke', + stream: false, + tools: [{ type: 'image_generation' }], + tool_choice: { type: 'image_generation' } + }) + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^application\/json\b/); + const body = await response.json(); + assert.equal(body.id, 'resp_fixture'); + assert.equal(body.output[0].type, 'image_generation_call'); + assert.equal(body.output[0].result, FIXTURE_IMAGE_BASE64); + } finally { + await fixture.close(); + } + }); + + it('serves Responses API SSE image events', async () => { + const fixture = await startFixture(); + try { + const response = await fetch(`${fixture.baseUrl}/v1/responses`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-5.4', + input: 'fixture responses sse smoke', + stream: true, + tools: [{ type: 'image_generation' }], + tool_choice: { type: 'image_generation' } + }) + }); + + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /^text\/event-stream\b/); + const events = readSseEvents(await response.text()); + assert.deepEqual( + events.filter((event) => event.event).map((event) => event.event), + [ + 'response.image_generation_call.partial_image', + 'response.output_item.done', + 'response.completed' + ] + ); + assert.equal(events[0].data.partial_image_b64, FIXTURE_IMAGE_BASE64); + assert.equal(events[1].data.item.type, 'image_generation_call'); + assert.equal(events[1].data.item.result, FIXTURE_IMAGE_BASE64); + assert.equal(events[2].data.response.output[0].result, FIXTURE_IMAGE_BASE64); + assert.equal(events[3].done, true); + } finally { + await fixture.close(); + } + }); + + it('serves model discovery and health checks', async () => { + const fixture = await startFixture(); + try { + const healthResponse = await fetch(`${fixture.baseUrl}/health`); + const modelsResponse = await fetch(`${fixture.baseUrl}/v1/models`); + + assert.equal(healthResponse.status, 200); + assert.deepEqual(await healthResponse.json(), { ok: true }); + assert.equal(modelsResponse.status, 200); + const models = await modelsResponse.json(); + assert.deepEqual( + models.data.map((model) => model.id), + ['gpt-image-2', 'gpt-5.4'] + ); + } finally { + await fixture.close(); + } + }); + + it('rejects oversized JSON request bodies', async () => { + const fixture = await startFixture(); + try { + const response = await fetch(`${fixture.baseUrl}/v1/images/generations`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'gpt-image-2', + prompt: 'x'.repeat(MAX_JSON_BODY_BYTES) + }) + }); + + assert.equal(response.status, 413); + const body = await response.json(); + assert.match(body.error.message, /too large/); + } finally { + await fixture.close(); + } + }); +}); + +async function startFixture() { + const server = createFixtureServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}`, + close: () => closeServer(server) + }; +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function readSseEvents(raw) { + return raw + .trim() + .split(/\n\n+/) + .map((chunk) => readSseEvent(chunk)) + .filter(Boolean); +} + +function readSseEvent(chunk) { + const lines = chunk.split(/\n/); + const comment = lines.find((line) => line.startsWith(': ')); + const event = lines.find((line) => line.startsWith('event: ')); + const data = lines.find((line) => line.startsWith('data: ')); + if (comment) return { comment: comment.slice(2) }; + if (!data) return undefined; + if (data === 'data: [DONE]') return { done: true }; + return { + event: event ? event.slice('event: '.length) : undefined, + data: JSON.parse(data.slice('data: '.length)) + }; +} diff --git a/scripts/smoke-hf-space-memory.mjs b/scripts/smoke-hf-space-memory.mjs index e4b59777312e3a31023a3228e5be96aa8026c30b..5c6e97a9a5a0111152c2af1115f83de4c0876ef6 100644 --- a/scripts/smoke-hf-space-memory.mjs +++ b/scripts/smoke-hf-space-memory.mjs @@ -1,11 +1,14 @@ #!/usr/bin/env node import { spawnSync } from 'node:child_process'; +import { readPositiveIntegerEnv } from './env-utils.mjs'; + const imageName = process.env.HF_SPACE_SMOKE_IMAGE || 'gpt-image-playground:hf-space-memory-smoke'; const containerName = process.env.HF_SPACE_SMOKE_CONTAINER || 'gpt-image-playground-hf-space-smoke'; const hostPort = process.env.HF_SPACE_SMOKE_PORT || '4785'; const token = process.env.HF_SPACE_SMOKE_AGENT_TOKEN || 'hf-space-smoke-token'; const baseUrl = `http://127.0.0.1:${hostPort}`; +const readyTimeoutMs = readPositiveIntegerEnv('HF_SPACE_SMOKE_READY_TIMEOUT_MS', 45_000); function run(command, args, options = {}) { const result = spawnSync(command, args, { @@ -36,7 +39,7 @@ async function fetchJson(path, options = {}) { } async function waitForReady() { - const deadline = Date.now() + 45_000; + const deadline = Date.now() + readyTimeoutMs; let lastError; while (Date.now() < deadline) { try { diff --git a/scripts/smoke-hf-space-memory.test.mjs b/scripts/smoke-hf-space-memory.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..e4cdd76ae9a1a9c3b05c2e627e66926cb496572f --- /dev/null +++ b/scripts/smoke-hf-space-memory.test.mjs @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const scriptPath = join(repoRoot, 'scripts/smoke-hf-space-memory.mjs'); + +describe('HF Space memory smoke script validation', () => { + it('rejects invalid ready timeout values before Docker access', () => { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { ...process.env, HF_SPACE_SMOKE_READY_TIMEOUT_MS: '45s' } + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /HF_SPACE_SMOKE_READY_TIMEOUT_MS/); + assert.match(result.stderr, /positive integer/); + assert.equal(result.stdout.trim(), ''); + }); +}); diff --git a/scripts/smoke-image-upstream-compat.mjs b/scripts/smoke-image-upstream-compat.mjs new file mode 100644 index 0000000000000000000000000000000000000000..7aa58c5b004d79aeb067d7669b8e550d19abaa94 --- /dev/null +++ b/scripts/smoke-image-upstream-compat.mjs @@ -0,0 +1,310 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import http from 'node:http'; +const PNG_BASE64 = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; +const originalEnv = { ...process.env }; +function restoreProcessEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnv)) process.env[key] = value; +} +function configureRouteEnv() { + for (const key of [ + 'APP_PASSWORD', + 'OPENAI_API_KEY', + 'OPENAI_API_BASE_URL', + 'OPENAI_CHANNEL_1_API_KEYS', + 'OPENAI_CHANNEL_1_BASE_URL', + 'IMAGE_GENERATION_BACKEND', + 'IMAGE_STREAMING_STRATEGY' + ]) { + delete process.env[key]; + } + process.env.APP_LOG_LEVEL = 'warn'; + process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'indexeddb'; + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; +} +function imageRequest(input) { + const formData = new FormData(); + for (const [key, value] of Object.entries({ + mode: 'generate', + prompt: input.prompt || 'local image upstream compatibility smoke', + model: 'gpt-image-2', + n: '1', + size: '1024x1024', + quality: 'high', + output_format: 'png', + apiBaseUrl: input.apiBaseUrl, + apiKey: 'local-smoke-key', + clientRequestId: input.clientRequestId || 'image-upstream-compat-smoke' + })) { + formData.append(key, value); + } + if (input.imageBackend) formData.append('imageBackend', input.imageBackend); + if (input.imageStreamingStrategy) formData.append('imageStreamingStrategy', input.imageStreamingStrategy); + if (input.responsesModel) formData.append('responsesModel', input.responsesModel); + if (input.stream) { + formData.append('stream', 'true'); + formData.append('partial_images', '2'); + } + return new Request('http://localhost/api/images', { method: 'POST', body: formData }); +} +async function readBody(request) { + const chunks = []; + for await (const chunk of request) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + return Buffer.concat(chunks).toString('utf8'); +} +function sendJson(response, status, payload) { + response.writeHead(status, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(payload)); +} +function sendSse(response, events) { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const event of events) { + if (event.comment) response.write(`: ${event.comment}\n\n`); + if (event.event) response.write(`event: ${event.event}\n`); + if ('data' in event) response.write(`data: ${JSON.stringify(event.data)}\n\n`); + } + response.write('data: [DONE]\n\n'); + response.end(); +} +function costLogResponse() { + const now = Math.floor(Date.now() / 1000); + return { + success: true, + data: [{ id: now, type: 2, model_name: 'gpt-image-2', quota: 3750, created_at: now, request_id: 'local-smoke' }] + }; +} +async function startMockUpstream(handler) { + const calls = []; + const server = http.createServer(async (request, response) => { + try { + if (request.method === 'GET' && request.url === '/api/log/token') { + sendJson(response, 200, costLogResponse()); + return; + } + const body = await readBody(request); + calls.push({ method: request.method, url: request.url, body }); + await handler({ body, request, response }); + } catch (error) { + sendJson(response, 500, { error: { message: error instanceof Error ? error.message : String(error) } }); + } + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + calls, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} +function assertPath(request, expectedPath) { + assert.equal(request.method, 'POST'); + assert.equal(request.url?.endsWith(expectedPath), true); +} +async function readJsonImage(response) { + assert.equal(response.status, 200); + assert.match(response.headers.get('content-type') || '', /application\/json/); + const body = await response.json(); + assert.equal(Array.isArray(body.images), true); + assert.equal(body.images.length, 1); + assert.equal(body.images[0].b64_json, PNG_BASE64); + return body; +} +async function readSseImages(response, expectedTypes) { + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = (await response.text()) + .split('\n\n') + .map((part) => part.trim()) + .filter((part) => part.startsWith('data: ') && part !== 'data: [DONE]') + .map((part) => JSON.parse(part.slice('data: '.length))); + assert.deepEqual( + events.map((event) => event.type), + expectedTypes + ); + const done = events.at(-1); + assert.equal(done?.type, 'done'); + assert.equal(done.images.length, 1); + assert.equal(done.images[0].b64_json, PNG_BASE64); + return events; +} +function completedImageEvent(type, data) { + return { event: type, data: { type, ...data } }; +} +function responsesOutputItemDone(result) { + return { + event: 'response.output_item.done', + data: { type: 'response.output_item.done', item: { type: 'image_generation_call', status: 'completed', result } } + }; +} +const cases = [ + { + name: 'original new-api Images API JSON', + form: { stream: false, clientRequestId: 'smoke-images-json' }, + handle: ({ body, request, response }) => { + assertPath(request, '/v1/images/generations'); + assert.equal(JSON.parse(body).stream, false); + sendJson(response, 200, { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: PNG_BASE64 }], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } + }); + }, + verify: async (response) => { + await readJsonImage(response); + } + }, + { + name: 'sub2api Images API JSON', + form: { stream: false, clientRequestId: 'smoke-sub2api-images-json' }, + handle: ({ body, request, response }) => { + assertPath(request, '/v1/images/generations'); + const requestJson = JSON.parse(body); + assert.equal(requestJson.stream, false); + assert.equal(Object.hasOwn(requestJson, 'partial_images'), false); + sendJson(response, 200, { + created: Math.floor(Date.now() / 1000), + data: [{ b64_json: PNG_BASE64 }], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } + }); + }, + verify: async (response) => { + await readJsonImage(response); + } + }, + { + name: 'gaoren new-api Images SSE keepalive', + form: { stream: true, imageStreamingStrategy: 'newapi-keepalive-sse', clientRequestId: 'smoke-gaoren-keepalive' }, + handle: ({ body, request, response }) => { + assertPath(request, '/v1/images/generations'); + const requestJson = JSON.parse(body); + assert.equal(requestJson.stream, true); + assert.equal(requestJson.partial_images, 2); + sendSse(response, [ + { comment: 'keepalive' }, + completedImageEvent('image_generation.partial_image', { + b64_json: 'gaoren-partial-base64', + partial_image_index: 0 + }), + completedImageEvent('image_generation.completed', { b64_json: PNG_BASE64 }) + ]); + }, + verify: async (response) => { + const events = await readSseImages(response, ['partial_image', 'completed', 'done']); + assert.equal(events[0].b64_json, 'gaoren-partial-base64'); + } + }, + { + name: 'gaoren new-api JSON-as-SSE completed event', + form: { stream: true, imageStreamingStrategy: 'newapi-keepalive-sse', clientRequestId: 'smoke-gaoren-json-as-sse' }, + handle: ({ request, response }) => { + assertPath(request, '/v1/images/generations'); + sendSse(response, [completedImageEvent('image_generation.completed', { data: [{ b64_json: PNG_BASE64 }] })]); + }, + verify: async (response) => { + await readSseImages(response, ['completed', 'done']); + } + }, + { + name: 'sub2api Images SSE', + form: { stream: true, imageStreamingStrategy: 'openai-sse', clientRequestId: 'smoke-sub2api-images-sse' }, + handle: ({ request, response }) => { + assertPath(request, '/v1/images/generations'); + sendSse(response, [ + { event: 'image.generation.chunk', data: { b64_json: 'sub2api-partial-base64' } }, + { event: 'image.generation.result', data: { data: [{ b64_json: PNG_BASE64 }] } } + ]); + }, + verify: async (response) => { + const events = await readSseImages(response, ['partial_image', 'completed', 'done']); + assert.equal(events[0].b64_json, 'sub2api-partial-base64'); + } + }, + { + name: 'sub2api Responses image_generation bridge JSON', + form: { + stream: false, + imageBackend: 'responses-image-generation', + responsesModel: 'gpt-5.4', + clientRequestId: 'smoke-responses-bridge-json' + }, + handle: ({ body, request, response }) => { + assertPath(request, '/v1/responses'); + const requestJson = JSON.parse(body); + assert.equal(requestJson.model, 'gpt-5.4'); + assert.equal(requestJson.stream, false); + assert.equal(requestJson.tool_choice.type, 'image_generation'); + sendJson(response, 200, { + output: [{ type: 'image_generation_call', status: 'completed', result: PNG_BASE64 }], + usage: { input_tokens: 4, output_tokens: 5, total_tokens: 9 } + }); + }, + verify: async (response) => { + await readJsonImage(response); + } + }, + { + name: 'GPT2Image Responses image_generation SSE', + form: { + stream: true, + imageBackend: 'responses', + imageStreamingStrategy: 'responses-sse', + responsesModel: 'gpt-5.4', + clientRequestId: 'smoke-gpt2image-responses-sse' + }, + handle: ({ body, request, response }) => { + assertPath(request, '/v1/responses'); + const requestJson = JSON.parse(body); + assert.equal(requestJson.stream, true); + assert.equal(requestJson.tools[0].partial_images, 2); + sendSse(response, [ + completedImageEvent('response.image_generation_call.partial_image', { + partial_image_b64: 'gpt2image-partial-base64', + partial_image_index: 0 + }), + completedImageEvent('response.image_generation_call.completed', { item_id: 'ig_smoke' }), + responsesOutputItemDone(`data:image/png;base64,${PNG_BASE64}`), + completedImageEvent('response.completed', { + response: { output: [{ type: 'image_generation_call', status: 'completed', result: PNG_BASE64 }] } + }) + ]); + }, + verify: async (response) => { + const events = await readSseImages(response, ['partial_image', 'completed', 'done']); + assert.equal(events[0].b64_json, 'gpt2image-partial-base64'); + } + } +]; + +async function runCase(POST, testCase) { + const upstream = await startMockUpstream(testCase.handle); + try { + const response = await POST(imageRequest({ ...testCase.form, apiBaseUrl: upstream.baseUrl })); + await testCase.verify(response, upstream.calls); + console.log(`[pass] ${testCase.name}`); + } finally { + await upstream.close(); + } +} + +async function main() { + configureRouteEnv(); + const { POST } = await import('../src/app/api/images/route.ts'); + try { + for (const testCase of cases) await runCase(POST, testCase); + console.log('image upstream compatibility mock smoke passed'); + } finally { + restoreProcessEnv(); + } +} + +main().catch((error) => { + restoreProcessEnv(); + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/smoke-image-upstream-local-final-gate.mjs b/scripts/smoke-image-upstream-local-final-gate.mjs new file mode 100644 index 0000000000000000000000000000000000000000..0999068c718c9e96b04928b31219d02d0e08fd46 --- /dev/null +++ b/scripts/smoke-image-upstream-local-final-gate.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node + +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { commandFailureMessage, parseJsonPayload, printJson } from './command-center-utils.mjs'; +import { createFixtureServer } from './local-image-upstream-fixture.mjs'; + +const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url)); +const REAL_SMOKE_SCRIPT = fileURLToPath(new URL('./smoke-image-upstream-real.mjs', import.meta.url)); +const DEFAULT_TIMEOUT_MS = 30_000; +const LOCAL_FINAL_GATE_CASE_COUNT = 5; +const LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS = 15_000; + +function parseArgs(argv) { + const parsed = { help: false, timeoutMs: DEFAULT_TIMEOUT_MS }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--help' || arg === '-h') parsed.help = true; + else if (arg === '--timeout-ms') parsed.timeoutMs = readTimeoutMs(readArgValue(argv, (index += 1), arg), arg); + else throw new Error(`未知参数:${arg}`); + } + return parsed; +} + +function printHelp() { + console.log(`Usage: + npm run smoke:image-upstream-local + npm run smoke:image-upstream-local -- --timeout-ms 30000 + +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.`); +} + +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + const fixture = await startFixture(); + try { + const result = await runLocalFinalGate(fixture.baseUrl, options.timeoutMs); + const report = parseJsonPayload(result.stdout, 'local image upstream final gate'); + if (!result.ok) { + printJson({ + ok: false, + local_fixture: true, + upstream_host: readHost(fixture.baseUrl), + report, + output: commandFailureMessage(result) + }); + process.exit(1); + } + assertFinalGateReport(report); + printJson({ + ok: true, + local_fixture: true, + upstream_host: readHost(fixture.baseUrl), + final_gate_satisfied: report.final_gate_satisfied, + independent_targets: report.independent_targets, + results: report.results.map(summarizeResult) + }); + } finally { + await fixture.close(); + } +} + +function runLocalFinalGate(baseUrl, timeoutMs) { + return runCommandAsync( + process.execPath, + ['--import', 'tsx', REAL_SMOKE_SCRIPT, '--allow-billable', '--require-independent-targets', '--timeout-ms', String(timeoutMs)], + { + cwd: REPO_ROOT, + env: buildLocalFinalGateEnv(baseUrl, timeoutMs), + timeoutMs: timeoutMs * LOCAL_FINAL_GATE_CASE_COUNT + LOCAL_FINAL_GATE_PARENT_TIMEOUT_BUFFER_MS + } + ); +} + +function runCommandAsync(command, args, options = {}) { + const startedAt = Date.now(); + return new Promise((resolve) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: options.env, + stdio: ['ignore', 'pipe', 'pipe'] + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + const timeout = setTimeout(() => { + timedOut = true; + child.kill('SIGTERM'); + }, options.timeoutMs); + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('error', (error) => { + clearTimeout(timeout); + resolve({ + ok: false, + command, + args, + stdout, + stderr, + elapsed_ms: Date.now() - startedAt, + error: error.message + }); + }); + child.on('close', (status, signal) => { + clearTimeout(timeout); + resolve({ + ok: status === 0, + command, + args, + status, + stdout, + stderr, + elapsed_ms: Date.now() - startedAt, + ...(signal ? { signal } : {}), + ...(timedOut ? { error: `timed out after ${options.timeoutMs}ms` } : {}) + }); + }); + }); +} + +function buildLocalFinalGateEnv(baseUrl, timeoutMs) { + const env = stripSmokeEnv(process.env); + return { + ...env, + IMAGE_REAL_SMOKE_SKIP_DOTENV: '1', + IMAGE_REAL_SMOKE_TIMEOUT_MS: String(timeoutMs), + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'local-fixture-key-original', + IMAGE_REAL_SMOKE_GAOREN_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'local-fixture-key-gaoren', + IMAGE_REAL_SMOKE_SUB2API_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'local-fixture-key-sub2api', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'local-fixture-key-sub2api-responses', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL: 'gpt-5.4', + IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'local-fixture-key-gpt2image', + IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL: 'gpt-5.4' + }; +} + +function stripSmokeEnv(source) { + const env = { ...source }; + for (const key of Object.keys(env)) { + if (isSmokeEnvKey(key)) delete env[key]; + } + return env; +} + +function isSmokeEnvKey(key) { + return ( + key.startsWith('IMAGE_REAL_SMOKE_') || + key.startsWith('OPENAI_CHANNEL_') || + key === 'OPENAI_API_BASE_URL' || + key === 'OPENAI_API_KEY' || + key === 'OPENAI_RESPONSES_API_MODEL' || + key === 'OPENAI_ROUTING_STRATEGY' || + key === 'OPENAI_CHANNELS_JSON' || + key === 'IMAGE_OUTPUT_DIR' || + key === 'APP_PASSWORD' || + key === 'AGENT_API_TOKEN' + ); +} + +async function startFixture() { + const server = createFixtureServer(); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address !== 'object') throw new Error('local fixture did not expose a TCP address'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => closeServer(server) + }; +} + +function closeServer(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function assertFinalGateReport(report) { + if (report?.final_gate_satisfied !== true) throw new Error('local final gate did not satisfy final_gate_satisfied=true'); + if (!Array.isArray(report.results) || report.results.length !== 5) { + throw new Error('local final gate did not run all five independent upstream cases'); + } + const failed = report.results.find((item) => item.ok !== true || item.skipped === true); + if (failed) throw new Error(`local final gate case failed: ${failed.id || 'unknown'}`); +} + +function summarizeResult(result) { + return { + id: result.id, + status: result.status, + content_type: result.content_type, + event_types: result.event_types, + image_count: result.image_count, + done_image_count: result.done_image_count, + first_b64_length: result.first_b64_length, + elapsed_ms: result.elapsed_ms + }; +} + +function readArgValue(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${flag} 缺少参数值。`); + return value; +} + +function readTimeoutMs(value, source) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1000) throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`); + return parsed; +} + +function readHost(value) { + try { + return new URL(value).host; + } catch { + return 'invalid-url'; + } +} + +main().catch((error) => { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +}); diff --git a/scripts/smoke-image-upstream-local-final-gate.test.mjs b/scripts/smoke-image-upstream-local-final-gate.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..4ca41f7457e358500521a159fb31eb690a358a20 --- /dev/null +++ b/scripts/smoke-image-upstream-local-final-gate.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const scriptPath = fileURLToPath(new URL('./smoke-image-upstream-local-final-gate.mjs', import.meta.url)); + +describe('local image upstream final gate smoke launcher', () => { + it('runs all independent upstream cases against the local fixture', () => { + const result = spawnSync(process.execPath, [scriptPath, '--timeout-ms', '30000'], { + cwd: repoRoot, + encoding: 'utf8' + }); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /local-fixture-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.local_fixture, true); + assert.equal(report.final_gate_satisfied, true); + assert.equal(report.independent_targets.configuration_complete, true); + assert.equal(report.results.length, 5); + assert.deepEqual( + report.results.map((item) => item.id), + [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ] + ); + assert.equal(report.results.every((item) => item.status === 200), true); + assert.equal(report.results.every((item) => item.first_b64_length === 92), true); + }); + + it('prints help without starting the fixture', () => { + const result = spawnSync(process.execPath, [scriptPath, '--help'], { + cwd: repoRoot, + encoding: 'utf8' + }); + + assert.equal(result.status, 0); + assert.match(result.stdout, /smoke:image-upstream-local/); + assert.match(result.stdout, /local fixture gate/); + }); +}); diff --git a/scripts/smoke-image-upstream-real.mjs b/scripts/smoke-image-upstream-real.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c149067cf31b56b2b5feb2e82b6d2b9922091a78 --- /dev/null +++ b/scripts/smoke-image-upstream-real.mjs @@ -0,0 +1,791 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; +import fs from 'node:fs'; + +const originalEnv = { ...process.env }; +const CASES = [ + { id: 'original-images-json', prefix: 'IMAGE_REAL_SMOKE_ORIGINAL', stream: false }, + { id: 'gaoren-images-sse', prefix: 'IMAGE_REAL_SMOKE_GAOREN', stream: true, strategy: 'newapi-keepalive-sse' }, + { id: 'sub2api-images-sse', prefix: 'IMAGE_REAL_SMOKE_SUB2API', stream: true, strategy: 'newapi-keepalive-sse' }, + { + id: 'sub2api-responses-json', + prefix: 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES', + fallbackPrefix: 'IMAGE_REAL_SMOKE_SUB2API', + stream: false, + backend: 'responses-image-generation' + }, + { + id: 'gpt2image-responses-sse', + prefix: 'IMAGE_REAL_SMOKE_GPT2IMAGE', + stream: true, + strategy: 'responses-sse', + backend: 'responses-image-generation' + } +]; +const SERVER_CHANNEL_CASES = [ + { id: 'server-channel-images-json', prefix: 'IMAGE_REAL_SMOKE_SERVER', stream: false, serverChannel: true }, + { + id: 'server-channel-images-sse', + prefix: 'IMAGE_REAL_SMOKE_SERVER', + stream: true, + strategy: 'newapi-keepalive-sse', + serverChannel: true + }, + { + id: 'server-channel-responses-sse', + prefix: 'IMAGE_REAL_SMOKE_SERVER', + stream: true, + strategy: 'responses-sse', + backend: 'responses-image-generation', + serverChannel: true + }, + { + id: 'server-channel-responses-json', + prefix: 'IMAGE_REAL_SMOKE_SERVER', + stream: false, + backend: 'responses-image-generation', + serverChannel: true + }, + { + id: 'server-channel-agent-images-sse', + prefix: 'IMAGE_REAL_SMOKE_SERVER', + stream: true, + strategy: 'newapi-keepalive-sse', + serverChannel: true, + endpoint: 'agent-generate' + }, + { + id: 'server-channel-agent-responses-sse', + prefix: 'IMAGE_REAL_SMOKE_SERVER', + stream: true, + strategy: 'responses-sse', + backend: 'responses-image-generation', + serverChannel: true, + endpoint: 'agent-generate' + } +]; + +const argv = process.argv.slice(2); +let options; +let exitCode = 0; +let forceExitAfterReport = false; +try { + if (!isHelpRequested(argv)) loadDotEnvFiles(argv); + options = parseArgs(argv); + if (options.help) { + printUsage(); + } else { + configureRouteEnv(); + const availableCases = options.includeServerChannel ? [...CASES, ...SERVER_CHANNEL_CASES] : CASES; + const selectedCases = availableCases.filter( + (testCase) => options.caseId === 'all' || testCase.id === options.caseId + ); + if (selectedCases.length === 0) throw new Error(`未知真实 smoke 场景:${options.caseId}`); + const billablePreflight = buildBillablePreflight(selectedCases); + const results = []; + let routeHandlers; + for (const testCase of selectedCases) { + const loadHandlers = async () => { + routeHandlers ||= await loadRouteHandlers(); + return routeHandlers; + }; + const result = await runCase(loadHandlers, testCase, billablePreflight); + results.push(result); + if (result.timed_out) break; + } + const independentTargetSummary = buildIndependentTargetSummary(results, options.requireIndependentTargets); + const unselectedRequiredCases = options.requireIndependentTargets + ? independentTargetSummary?.unselected_required_cases || [] + : []; + const invalidRequiredCases = options.requireIndependentTargets + ? independentTargetSummary?.invalid_cases || [] + : []; + const blockedCases = results.filter((item) => item.blocked).map((item) => item.id); + const blockedRequiredCases = options.requireIndependentTargets + ? blockedCases.filter((id) => CASES.some((testCase) => testCase.id === id)) + : []; + const skippedRequiredCases = options.requireIndependentTargets + ? results.filter((item) => item.skipped && !item.blocked && !item.server_channel).map((item) => item.id) + : []; + const missingRequiredCaseSet = new Set([...unselectedRequiredCases, ...skippedRequiredCases]); + const missingRequiredCases = CASES.map((testCase) => testCase.id).filter((id) => + missingRequiredCaseSet.has(id) + ); + const finalGateSatisfied = isFinalGateSatisfied(results, missingRequiredCases); + const report = { + ok: + results.every((item) => item.ok || item.skipped) && + missingRequiredCases.length === 0 && + invalidRequiredCases.length === 0, + billable: options.allowBillable, + final_gate_satisfied: finalGateSatisfied, + ...(independentTargetSummary ? { independent_targets: independentTargetSummary } : {}), + ...(unselectedRequiredCases.length > 0 ? { unselected_required_cases: unselectedRequiredCases } : {}), + ...(invalidRequiredCases.length > 0 ? { invalid_required_count: invalidRequiredCases.length } : {}), + ...(invalidRequiredCases.length > 0 ? { invalid_required_cases: invalidRequiredCases } : {}), + ...(blockedCases.length > 0 ? { blocked_count: blockedCases.length } : {}), + ...(blockedCases.length > 0 ? { blocked_cases: blockedCases } : {}), + ...(blockedRequiredCases.length > 0 ? { blocked_required_count: blockedRequiredCases.length } : {}), + ...(blockedRequiredCases.length > 0 ? { blocked_required_cases: blockedRequiredCases } : {}), + ...(skippedRequiredCases.length > 0 ? { skipped_required_cases: skippedRequiredCases } : {}), + ...(missingRequiredCases.length > 0 ? { missing_required_count: missingRequiredCases.length } : {}), + ...(missingRequiredCases.length > 0 ? { missing_required_cases: missingRequiredCases } : {}), + results + }; + forceExitAfterReport = results.some((item) => item.timed_out); + await writeStdout(`${JSON.stringify(report, null, 2)}\n`); + exitCode = report.ok ? 0 : 1; + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + exitCode = 1; +} finally { + restoreProcessEnv(); +} +if (forceExitAfterReport) { + process.exit(exitCode); +} +process.exitCode = exitCode; + +function writeStdout(text) { + return new Promise((resolve, reject) => { + process.stdout.write(text, (error) => { + if (error) reject(error); + else resolve(); + }); + }); +} + +function isFinalGateSatisfied(results, missingRequiredCases) { + if (!options.requireIndependentTargets || !options.allowBillable || missingRequiredCases.length > 0) return false; + const passedIndependentCases = new Set( + results.filter((item) => !item.server_channel && !item.skipped && item.ok).map((item) => item.id) + ); + return CASES.every((testCase) => passedIndependentCases.has(testCase.id)); +} + +function buildBillablePreflight(selectedCases) { + if (!options.allowBillable) return {}; + if (selectedCases.some((testCase) => readInvalidEnv(readTarget(testCase)).length > 0)) { + return { blockBillable: true, blockReason: 'blocked by invalid base url env' }; + } + if (options.requireIndependentTargets && !isRequiredIndependentGateConfigured(selectedCases)) { + return { blockBillable: true, blockReason: 'blocked by incomplete independent target configuration' }; + } + return {}; +} + +function isRequiredIndependentGateConfigured(selectedCases) { + const selectedIndependentCaseIds = new Set( + selectedCases.filter((testCase) => !testCase.serverChannel).map((testCase) => testCase.id) + ); + return CASES.every((testCase) => { + if (!selectedIndependentCaseIds.has(testCase.id)) return false; + const target = readTarget(testCase); + return readInvalidEnv(target).length === 0 && isRunnableTarget(target); + }); +} + +function parseArgs(argv) { + const parsed = { + allowBillable: false, + caseId: 'all', + envFilePath: undefined, + help: false, + includeServerChannel: false, + requireIndependentTargets: false, + timeoutMs: readTimeoutMs(env('IMAGE_REAL_SMOKE_TIMEOUT_MS') || '240000', 'IMAGE_REAL_SMOKE_TIMEOUT_MS') + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === '--allow-billable') parsed.allowBillable = true; + else if (arg === '--env-file') parsed.envFilePath = readArgValue(argv, (index += 1), arg); + else if (arg === '--env-file-if-exists') parsed.envFileIfExistsPath = readArgValue(argv, (index += 1), arg); + else if (arg === '--include-server-channel') parsed.includeServerChannel = true; + else if (arg === '--require-independent-targets') parsed.requireIndependentTargets = true; + else if (arg === '--timeout-ms') parsed.timeoutMs = readTimeoutMs(readArgValue(argv, (index += 1), arg), arg); + else if (arg === '--case') parsed.caseId = readArgValue(argv, (index += 1), arg); + else if (arg === '--help' || arg === '-h') parsed.help = true; + else throw new Error(`未知参数:${arg}`); + } + return parsed; +} + +function isHelpRequested(argv) { + return argv.includes('--help') || argv.includes('-h'); +} + +function readTimeoutMs(value, source) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1000) { + throw new Error(`${source} 必须是不小于 1000 的整数毫秒。`); + } + return parsed; +} + +function readArgValue(argv, index, flag) { + const value = argv[index]; + if (!value || value.startsWith('--')) throw new Error(`${flag} 缺少参数值。`); + return value; +} + +function loadDotEnvFiles(argv) { + if (env('IMAGE_REAL_SMOKE_SKIP_DOTENV') !== '1') { + loadEnvFileIfPresent('.env.local', { overrideLoadedValues: false }); + } + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== '--env-file') continue; + loadEnvFile(readArgValue(argv, (index += 1), '--env-file'), { overrideLoadedValues: true }); + } + for (let index = 0; index < argv.length; index += 1) { + if (argv[index] !== '--env-file-if-exists') continue; + loadEnvFileIfPresent(readArgValue(argv, (index += 1), '--env-file-if-exists'), { overrideLoadedValues: true }); + } +} + +function loadEnvFileIfPresent(filepath, options) { + if (!fs.existsSync(filepath)) return; + loadEnvFile(filepath, options); +} + +function loadEnvFile(filepath, options) { + if (!fs.existsSync(filepath)) throw new Error(`--env-file 指定的文件不存在:${filepath}`); + for (const line of fs.readFileSync(filepath, 'utf8').split(/\r?\n/)) { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || !shouldSetLoadedEnv(match[1], options)) continue; + process.env[match[1]] = match[2].replace(/^['"]|['"]$/g, '').trim(); + } +} + +function shouldSetLoadedEnv(key, options) { + if (originalEnv[key] !== undefined) return false; + return options.overrideLoadedValues || process.env[key] === undefined; +} + +function restoreProcessEnv() { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnv)) process.env[key] = value; +} + +function configureRouteEnv() { + process.env.APP_LOG_LEVEL = 'warn'; + process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'indexeddb'; + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.AGENT_STATE_BACKEND = 'memory'; + process.env.IMAGE_OUTPUT_DIR = process.env.IMAGE_OUTPUT_DIR || 'generated-images/.real-smoke'; +} + +async function loadRouteHandlers() { + const imageRoute = await import('../src/app/api/images/route.ts'); + const agentGenerateRoute = await import('../src/app/api/agent/images/generate/route.ts'); + return { + images: readPostHandler(imageRoute, '/api/images'), + agentGenerate: readPostHandler(agentGenerateRoute, '/api/agent/images/generate') + }; +} + +function readPostHandler(routeModule, label) { + const handler = routeModule.POST || routeModule.default?.POST || routeModule['module.exports']?.POST; + if (typeof handler !== 'function') { + throw new Error(`${label} route 缺少 POST handler。`); + } + return handler; +} + +async function runCase(loadRouteHandlersForBillable, testCase, preflight = {}) { + const target = readTarget(testCase); + const invalidEnv = readInvalidEnv(target); + if (invalidEnv.length > 0) return invalid(testCase, target, invalidEnv); + if (!isRunnableTarget(target)) return skipped(testCase, target); + if (preflight.blockBillable && options.allowBillable) return blocked(testCase, target, preflight.blockReason); + if (!options.allowBillable) { + return { + id: testCase.id, + skipped: true, + ok: true, + reason: 'requires --allow-billable', + upstream_host: readHost(target.baseUrl), + ...(target.serverChannel ? { server_channel: true } : {}) + }; + } + const startedAt = Date.now(); + const routeHandlers = await loadRouteHandlersForBillable(); + const abortController = new AbortController(); + return withCaseTimeout( + runBillableCase(routeHandlers, testCase, target, startedAt, abortController.signal), + testCase, + target, + startedAt, + abortController + ); +} + +async function runBillableCase(routeHandlers, testCase, target, startedAt, signal) { + const outputFilesBefore = snapshotRealSmokeOutputFiles(); + if (testCase.endpoint === 'agent-generate' && testCase.backend === 'responses-image-generation') { + process.env.OPENAI_RESPONSES_API_MODEL = target.responsesModel; + } + try { + const response = + testCase.endpoint === 'agent-generate' + ? await routeHandlers.agentGenerate(agentGenerateRequest(testCase, target, signal)) + : await routeHandlers.images(imageRequest(testCase, target, signal)); + const summary = + testCase.endpoint === 'agent-generate' + ? await summarizeAgentResponse(response) + : await summarizeResponse(response); + return { + id: testCase.id, + ok: isSuccessfulBillableSmokeResponse(response, summary), + status: response.status, + elapsed_ms: Date.now() - startedAt, + upstream_host: readHost(target.baseUrl), + ...(target.serverChannel ? { server_channel: true } : {}), + ...summary + }; + } finally { + removeNewRealSmokeOutputFiles(outputFilesBefore); + } +} + +function withCaseTimeout(promise, testCase, target, startedAt, abortController) { + return new Promise((resolve, reject) => { + let settled = false; + const timeout = setTimeout(() => { + settled = true; + abortController.abort(); + resolve({ + id: testCase.id, + ok: false, + timed_out: true, + elapsed_ms: Date.now() - startedAt, + upstream_host: readHost(target.baseUrl), + ...(target.serverChannel ? { server_channel: true } : {}), + error: `real upstream smoke timed out after ${options.timeoutMs}ms` + }); + }, options.timeoutMs); + promise.then( + (value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve(value); + }, + (error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + } + ); + }); +} + +function readTarget(testCase) { + const basePrefix = testCase.prefix; + const fallbackPrefix = testCase.fallbackPrefix || basePrefix; + const requiresResponsesModel = testCase.backend === 'responses-image-generation'; + if (testCase.serverChannel) { + const serverBaseUrl = readEnvEntry(`${basePrefix}_BASE_URL`) || readFirstConfiguredServerBaseUrl(); + return { + serverChannel: true, + requiresResponsesModel, + baseUrl: serverBaseUrl?.value, + baseUrlKey: serverBaseUrl?.key, + hasServerCredential: Boolean(env('OPENAI_API_KEY') || readFirstConfiguredServerApiKeys()), + model: env(`${basePrefix}_MODEL`) || 'gpt-image-2', + responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'), + size: env(`${basePrefix}_SIZE`) || '1024x1024', + quality: env(`${basePrefix}_QUALITY`) || 'low' + }; + } + const baseUrl = readFirstEnvEntry(readSmokeEnvAlternatives(testCase, 'BASE_URL')); + const apiKey = readFirstEnvEntry(readSmokeEnvAlternatives(testCase, 'API_KEY')); + return { + requiresResponsesModel, + baseUrl: baseUrl?.value, + baseUrlKey: baseUrl?.key, + apiKey: apiKey?.value, + model: env(`${basePrefix}_MODEL`) || env(`${fallbackPrefix}_MODEL`) || 'gpt-image-2', + responsesModel: env(`${basePrefix}_RESPONSES_MODEL`) || env('OPENAI_RESPONSES_API_MODEL'), + size: env(`${basePrefix}_SIZE`) || env(`${fallbackPrefix}_SIZE`) || '1024x1024', + quality: env(`${basePrefix}_QUALITY`) || env(`${fallbackPrefix}_QUALITY`) || 'low' + }; +} + +function isRunnableTarget(target) { + if (!target.baseUrl) return false; + if (target.requiresResponsesModel && !target.responsesModel) return false; + if (target.serverChannel) return target.hasServerCredential; + if (!target.apiKey) return false; + return true; +} + +function readInvalidEnv(target) { + if (!target.baseUrl || !target.baseUrlKey) return []; + const reason = readBaseUrlValidationReason(target.baseUrl); + return reason ? [{ key: target.baseUrlKey, reason }] : []; +} + +function readBaseUrlValidationReason(value) { + let url; + try { + url = new URL(value); + } catch { + return 'must_be_http_or_https_absolute_url'; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'must_use_http_or_https'; + if (url.username || url.password) return 'must_not_include_credentials'; + if (url.search || url.hash) return 'must_not_include_query_or_fragment'; + return undefined; +} + +function skipped(testCase, target) { + const missingEnvAny = readMissingEnvAny(testCase, target); + return { + id: testCase.id, + skipped: true, + ok: true, + reason: readSkippedReason(target), + ...(missingEnvAny.length > 0 ? { missing_env_any: missingEnvAny } : {}), + ...(target.serverChannel ? { server_channel: true } : {}) + }; +} + +function invalid(testCase, target, invalidEnv) { + return { + id: testCase.id, + ok: false, + invalid: true, + reason: 'invalid base url env', + invalid_env: invalidEnv, + ...(target.serverChannel ? { server_channel: true } : {}) + }; +} + +function blocked(testCase, target, reason = 'blocked by invalid base url env') { + return { + id: testCase.id, + skipped: true, + blocked: true, + ok: true, + reason, + upstream_host: readHost(target.baseUrl), + ...(target.serverChannel ? { server_channel: true } : {}) + }; +} + +function buildIndependentTargetSummary(results, requireIndependentTargets = false) { + const independentResults = results.filter((item) => !item.server_channel); + const requiredCases = CASES.map((testCase) => testCase.id); + if (independentResults.length === 0 && !requireIndependentTargets) return undefined; + const selectedCases = independentResults.map((item) => item.id); + const unselectedRequiredCases = requiredCases.filter((id) => !selectedCases.includes(id)); + const configuredCases = independentResults + .filter( + (item) => + (!Array.isArray(item.missing_env_any) || item.missing_env_any.length === 0) && + (!Array.isArray(item.invalid_env) || item.invalid_env.length === 0) + ) + .map((item) => item.id); + const missingCases = independentResults + .filter((item) => Array.isArray(item.missing_env_any) && item.missing_env_any.length > 0) + .map((item) => item.id); + const invalidCases = independentResults + .filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0) + .map((item) => item.id); + return { + required_count: requiredCases.length, + required_cases: requiredCases, + selected_cases: selectedCases, + unselected_required_count: unselectedRequiredCases.length, + unselected_required_cases: unselectedRequiredCases, + configuration_complete: + unselectedRequiredCases.length === 0 && missingCases.length === 0 && invalidCases.length === 0, + selected_count: independentResults.length, + configured_count: configuredCases.length, + missing_count: missingCases.length, + configured_cases: configuredCases, + missing_cases: missingCases, + invalid_count: invalidCases.length, + invalid_cases: invalidCases, + invalid_env: Object.fromEntries( + independentResults + .filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0) + .map((item) => [item.id, item.invalid_env]) + ), + final_gate_command: + 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable' + }; +} + +function readSkippedReason(target) { + if (!target.baseUrl) return target.serverChannel ? 'missing server channel base url env' : 'missing base url env'; + if (target.serverChannel && !target.hasServerCredential) return 'missing server channel api key env'; + if (!target.serverChannel && !target.apiKey) return 'missing api key env'; + if (target.requiresResponsesModel && !target.responsesModel) return 'missing responses model env'; + return target.serverChannel ? 'missing server channel api key env' : 'missing api key env'; +} + +function readMissingEnvAny(testCase, target) { + const groups = []; + if (!target.baseUrl) { + groups.push(readSmokeEnvAlternatives(testCase, 'BASE_URL')); + } + if (target.serverChannel && target.baseUrl && !target.hasServerCredential) { + groups.push(['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS']); + } + if (!target.serverChannel && target.baseUrl && !target.apiKey) { + groups.push(readSmokeEnvAlternatives(testCase, 'API_KEY')); + } + if (target.requiresResponsesModel && target.baseUrl && !target.responsesModel) { + groups.push(readResponsesModelEnvAlternatives(testCase)); + } + return groups; +} + +function readSmokeEnvAlternatives(testCase, suffix) { + const keys = [`${testCase.prefix}_${suffix}`]; + if (testCase.fallbackPrefix && testCase.fallbackPrefix !== testCase.prefix) { + keys.push(`${testCase.fallbackPrefix}_${suffix}`); + } + return keys; +} + +function readResponsesModelEnvAlternatives(testCase) { + return [`${testCase.prefix}_RESPONSES_MODEL`, 'OPENAI_RESPONSES_API_MODEL']; +} + +function env(key) { + const value = process.env[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function readEnvEntry(key) { + const value = env(key); + return value ? { key, value } : undefined; +} + +function readFirstEnvEntry(keys) { + for (const key of keys) { + const value = readEnvEntry(key); + if (value) return value; + } + return undefined; +} + +function readFirstConfiguredServerBaseUrl() { + return readEnvEntry('OPENAI_API_BASE_URL') || readFirstNumberedEnvEntry('OPENAI_CHANNEL_', '_BASE_URL'); +} + +function readFirstConfiguredServerApiKeys() { + return env('OPENAI_API_KEY') || readFirstNumberedEnv('OPENAI_CHANNEL_', '_API_KEYS'); +} + +function readFirstNumberedEnv(prefix, suffix) { + for (let index = 1; index <= 20; index += 1) { + const value = env(`${prefix}${index}${suffix}`); + if (value) return value; + } + return undefined; +} + +function readFirstNumberedEnvEntry(prefix, suffix) { + for (let index = 1; index <= 20; index += 1) { + const value = readEnvEntry(`${prefix}${index}${suffix}`); + if (value) return value; + } + return undefined; +} + +function imageRequest(testCase, target, signal) { + const formData = new FormData(); + const fields = { + mode: 'generate', + prompt: 'real upstream compatibility smoke', + model: target.model, + n: '1', + size: target.size, + quality: target.quality, + output_format: 'png', + clientRequestId: `real-smoke-${testCase.id}` + }; + for (const [key, value] of Object.entries(fields)) formData.append(key, value); + if (!target.serverChannel) { + formData.append('apiBaseUrl', target.baseUrl); + formData.append('apiKey', target.apiKey); + } + const pagePasswordHash = readAppPasswordHash(); + if (pagePasswordHash) formData.append('passwordHash', pagePasswordHash); + formData.append('imageBackend', testCase.backend || 'images-api'); + formData.append('imageStreamingStrategy', testCase.strategy || 'off'); + if (testCase.backend === 'responses-image-generation') formData.append('responsesModel', target.responsesModel); + if (testCase.stream) { + formData.append('stream', 'true'); + formData.append('partial_images', '2'); + } + return new Request('http://localhost/api/images', { method: 'POST', body: formData, signal }); +} + +function agentGenerateRequest(testCase, target, signal) { + return new Request('http://localhost/api/agent/images/generate', { + method: 'POST', + headers: buildAgentRequestHeaders(testCase), + body: JSON.stringify({ + prompt: 'real agent upstream sse compatibility smoke', + model: target.model, + n: 1, + size: target.size, + quality: target.quality, + output_format: 'png', + response_mode: 'path', + image_backend: testCase.backend || 'images-api', + streaming_strategy: testCase.strategy || 'off', + partial_images: 2 + }), + signal + }); +} + +function buildAgentRequestHeaders(testCase) { + return { + 'Content-Type': 'application/json', + 'Idempotency-Key': `real-smoke-${testCase.id}-${Date.now()}`, + ...readAgentAuthHeaders() + }; +} + +function readAgentAuthHeaders() { + const token = env('AGENT_API_TOKEN'); + if (token) return { Authorization: `Bearer ${token}` }; + const passwordHash = readAppPasswordHash(); + return passwordHash ? { 'X-App-Password-Hash': passwordHash } : {}; +} + +function readAppPasswordHash() { + const password = env('APP_PASSWORD'); + if (!password) return undefined; + return crypto.createHash('sha256').update(password).digest('hex'); +} + +async function summarizeResponse(response) { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) return summarizeSse(await response.text()); + return summarizeJson(await response.text(), contentType); +} + +function summarizeSse(text) { + const events = text + .split('\n\n') + .map((part) => part.trim()) + .filter((part) => part.startsWith('data: ') && part !== 'data: [DONE]') + .map((part) => safeJson(part.slice('data: '.length))) + .filter(Boolean); + const done = events.findLast((event) => event.type === 'done'); + const error = events.findLast((event) => event.type === 'error'); + return { + content_type: 'text/event-stream', + event_types: events.map((event) => event.type || 'unknown'), + done_image_count: Array.isArray(done?.images) ? done.images.length : 0, + first_b64_length: readFirstB64Length(done?.images), + ...(error ? { error: String(error.error || 'stream error') } : {}) + }; +} + +function isSuccessfulBillableSmokeResponse(response, summary) { + if (!response.ok || summary?.error) return false; + if (summary?.content_type === 'text/event-stream') { + return summary.done_image_count > 0; + } + if (typeof summary?.image_count === 'number') { + return summary.image_count > 0; + } + return false; +} + +function summarizeJson(text, contentType) { + const body = safeJson(text); + return { + content_type: contentType || undefined, + image_count: Array.isArray(body?.images) ? body.images.length : 0, + first_b64_length: readFirstB64Length(body?.images), + ...(body?.error ? { error: String(body.error) } : {}) + }; +} + +async function summarizeAgentResponse(response) { + const contentType = response.headers.get('content-type') || ''; + const body = safeJson(await response.text()); + return { + content_type: contentType || undefined, + image_count: Array.isArray(body?.images) ? body.images.length : 0, + first_content_url: typeof body?.images?.[0]?.content_url === 'string' ? body.images[0].content_url : undefined, + has_inline_base64: Boolean(body?.images?.[0]?.b64_json), + ...(body?.error ? { error: String(body.error.message || body.error) } : {}) + }; +} + +function readFirstB64Length(images) { + if (!Array.isArray(images) || typeof images[0]?.b64_json !== 'string') return 0; + return images[0].b64_json.length; +} + +function safeJson(text) { + try { + return JSON.parse(text); + } catch { + return undefined; + } +} + +function readHost(value) { + try { + return new URL(value).host; + } catch { + return 'invalid-url'; + } +} + +function snapshotRealSmokeOutputFiles() { + const outputDir = readRealSmokeOutputDir(); + if (!outputDir || !fs.existsSync(outputDir)) return new Set(); + return new Set(fs.readdirSync(outputDir).filter(isGeneratedImageFile)); +} + +function removeNewRealSmokeOutputFiles(filesBefore) { + const outputDir = readRealSmokeOutputDir(); + if (!outputDir || !fs.existsSync(outputDir)) return; + for (const filename of fs.readdirSync(outputDir).filter(isGeneratedImageFile)) { + if (filesBefore.has(filename)) continue; + fs.rmSync(`${outputDir}/${filename}`, { force: true }); + } +} + +function readRealSmokeOutputDir() { + return process.env.IMAGE_OUTPUT_DIR === 'generated-images/.real-smoke' ? process.env.IMAGE_OUTPUT_DIR : undefined; +} + +function isGeneratedImageFile(filename) { + return /\.(png|jpe?g|webp)$/i.test(filename); +} + +function printUsage() { + console.log(`用法:npm run smoke:image-upstream-real -- [--env-file ] [--env-file-if-exists ] [--allow-billable] [--include-server-channel] [--require-independent-targets] [--timeout-ms ] [--case ] + +环境变量前缀: + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL / IMAGE_REAL_SMOKE_ORIGINAL_API_KEY + IMAGE_REAL_SMOKE_GAOREN_BASE_URL / IMAGE_REAL_SMOKE_GAOREN_API_KEY + IMAGE_REAL_SMOKE_SUB2API_BASE_URL / IMAGE_REAL_SMOKE_SUB2API_API_KEY + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL / IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY + IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL / IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY + IMAGE_REAL_SMOKE_SERVER_* 可覆盖当前服务端渠道的 MODEL / SIZE / QUALITY / RESPONSES_MODEL + +可选 --case:all、original-images-json、gaoren-images-sse、sub2api-images-sse、sub2api-responses-json、gpt2image-responses-sse。 +添加 --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。 +默认只检查配置并跳过真实生图;必须加 --allow-billable 才会调用 /api/images 或 /api/agent/images/generate。 +可用 --env-file 指向独立真实上游凭据文件;shell 环境变量优先级高于 --env-file,--env-file 优先级高于 .env.local。 +可用 --env-file-if-exists 在凭据文件存在时加载,不存在时继续输出结构化 readiness 报告。 +添加 --require-independent-targets 后,任何独立真实上游场景未被选中或被跳过都会使脚本退出非零。默认单场景超时为 240000ms。`); +} diff --git a/scripts/smoke-image-upstream-real.test.mjs b/scripts/smoke-image-upstream-real.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a985efbc70538c1e31abaeb319ec3c51cf18a174 --- /dev/null +++ b/scripts/smoke-image-upstream-real.test.mjs @@ -0,0 +1,1070 @@ +import assert from 'node:assert/strict'; +import { spawn, spawnSync } from 'node:child_process'; +import { mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const scriptPath = join(repoRoot, 'scripts/smoke-image-upstream-real.mjs'); +const realSmokeOutputDir = join(repoRoot, 'generated-images/.real-smoke'); +const pngBase64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; + +describe('image upstream real smoke script', () => { + it('skips every real upstream target without billable calls when no target env is configured', () => { + const result = runScript(); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.billable, false); + assert.equal(report.results.length, 5); + assert.equal( + report.results.every((item) => item.skipped === true), + true + ); + assert.equal( + report.results.every((item) => item.reason === 'missing base url env'), + true + ); + assert.deepEqual(report.results[0].missing_env_any, [['IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL']]); + assert.deepEqual(report.results[3].missing_env_any, [ + ['IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL', 'IMAGE_REAL_SMOKE_SUB2API_BASE_URL'] + ]); + assert.deepEqual(report.independent_targets, { + required_count: 5, + required_cases: independentSmokeCaseIds(), + selected_cases: independentSmokeCaseIds(), + unselected_required_count: 0, + unselected_required_cases: [], + configuration_complete: false, + selected_count: 5, + configured_count: 0, + missing_count: 5, + configured_cases: [], + missing_cases: independentSmokeCaseIds(), + invalid_count: 0, + invalid_cases: [], + invalid_env: {}, + final_gate_command: + 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable' + }); + }); + + it('does not run configured real upstreams without explicit billable consent or leak API keys', () => { + const result = runScript(['--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://example.test/v1', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key' + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results.length, 1); + assert.equal(report.results[0].reason, 'requires --allow-billable'); + assert.equal(report.results[0].upstream_host, 'example.test'); + assert.equal('missing_env_any' in report.results[0], false); + assert.deepEqual(report.independent_targets.required_cases, independentSmokeCaseIds()); + assert.deepEqual(report.independent_targets.selected_cases, ['original-images-json']); + assert.deepEqual(report.independent_targets.unselected_required_cases, [ + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.equal(report.independent_targets.configuration_complete, false); + assert.deepEqual(report.independent_targets.configured_cases, ['original-images-json']); + assert.deepEqual(report.independent_targets.missing_cases, []); + }); + + it('loads independent real upstream targets from an explicit env file without leaking API keys', () => { + const envFilePath = join(repoRoot, 'generated-images/.real-smoke-test.env'); + try { + writeEnvFile( + envFilePath, + [ + 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://env-file.example/v1', + 'IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=secret-env-file-key' + ].join('\n') + ); + const result = runScript(['--env-file', envFilePath, '--case', 'original-images-json']); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /secret-env-file-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].reason, 'requires --allow-billable'); + assert.equal(report.results[0].upstream_host, 'env-file.example'); + } finally { + rmSync(envFilePath, { force: true }); + } + }); + + it('keeps shell environment values ahead of explicit env file values', () => { + const envFilePath = join(repoRoot, 'generated-images/.real-smoke-test.env'); + try { + writeEnvFile( + envFilePath, + [ + 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL=https://env-file.example/v1', + 'IMAGE_REAL_SMOKE_ORIGINAL_API_KEY=secret-env-file-key' + ].join('\n') + ); + const result = runScript(['--env-file', envFilePath, '--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://shell.example/v1', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-shell-key' + }); + + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /secret-env-file-key|secret-shell-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].upstream_host, 'shell.example'); + } finally { + rmSync(envFilePath, { force: true }); + } + }); + + it('reports the missing API key env when a real upstream base URL is configured alone', () => { + const result = runScript(['--case', 'gaoren-images-sse'], { + IMAGE_REAL_SMOKE_GAOREN_BASE_URL: 'https://gaoren.example/v1' + }); + + assert.equal(result.status, 0); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].reason, 'missing api key env'); + assert.deepEqual(report.results[0].missing_env_any, [['IMAGE_REAL_SMOKE_GAOREN_API_KEY']]); + }); + + it('reports unsafe real upstream base URLs in the structured readiness summary', () => { + const result = runScript(['--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@example.test/v1?token=secret#frag', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key' + }); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /user:pass|example\.test|secret-real-smoke-key|token=secret/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.independent_targets.configuration_complete, false); + assert.equal(report.independent_targets.configured_count, 0); + assert.equal(report.independent_targets.invalid_count, 1); + assert.deepEqual(report.independent_targets.invalid_cases, ['original-images-json']); + assert.deepEqual(report.independent_targets.invalid_env['original-images-json'], [ + { + key: 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL', + reason: 'must_not_include_credentials' + } + ]); + assert.deepEqual(report.results[0].invalid_env, [ + { + key: 'IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL', + reason: 'must_not_include_credentials' + } + ]); + }); + + it('does not make billable upstream calls when any selected target has unsafe configuration', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync(['--allow-billable', '--require-independent-targets'], { + ...buildAllIndependentTargetEnv(upstream.baseUrl), + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@example.test/v1?token=secret#frag' + }); + + assert.equal(result.status, 1); + assert.doesNotMatch(result.stdout, /user:pass|example\.test|secret-independent-key|token=secret/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.deepEqual(report.invalid_required_cases, ['original-images-json']); + assert.deepEqual(report.blocked_required_cases, [ + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.equal(upstream.calls.length, 0); + } finally { + await upstream.close(); + } + }); + + it('reports blocked cases at the top level for non-final-gate billable smoke runs', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync(['--allow-billable'], { + ...buildAllIndependentTargetEnv(upstream.baseUrl), + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://user:pass@example.test/v1?token=secret#frag' + }); + + assert.equal(result.status, 1); + const report = JSON.parse(result.stdout); + assert.deepEqual(report.blocked_cases, [ + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.equal(upstream.calls.length, 0); + } finally { + await upstream.close(); + } + }); + + it('does not make billable upstream calls when the required independent gate is missing target config', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync(['--allow-billable', '--require-independent-targets'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key' + }); + + assert.equal(result.status, 1); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.deepEqual(report.blocked_required_cases, ['original-images-json']); + assert.deepEqual(report.skipped_required_cases, [ + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.equal(upstream.calls.length, 0); + } finally { + await upstream.close(); + } + }); + + it('fails the run when independent upstream targets are required but skipped', () => { + const result = runScript(['--require-independent-targets']); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.equal(report.final_gate_satisfied, false); + assert.deepEqual(report.skipped_required_cases, [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.equal('unselected_required_cases' in report, false); + assert.equal(report.missing_required_count, 5); + assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds()); + }); + + it('fails the required independent upstream gate when billable consent is missing', () => { + const result = runScript(['--require-independent-targets', '--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: 'https://example.test/v1', + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key' + }); + + assert.equal(result.status, 1); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.equal(report.final_gate_satisfied, false); + assert.deepEqual(report.skipped_required_cases, ['original-images-json']); + assert.deepEqual(report.unselected_required_cases, [ + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]); + assert.equal(report.missing_required_count, 5); + assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds()); + assert.equal(report.results[0].reason, 'requires --allow-billable'); + }); + + it('can include current server channel smoke cases without explicit billable consent or leaking keys', () => { + const result = runScript(['--include-server-channel', '--case', 'server-channel-agent-images-sse'], { + OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1', + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key' + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /secret-server-channel-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results.length, 1); + assert.equal(report.results[0].reason, 'requires --allow-billable'); + assert.equal(report.results[0].server_channel, true); + assert.equal(report.results[0].upstream_host, 'server-channel.example'); + assert.equal('independent_targets' in report, false); + }); + + it('fails the required independent upstream gate when only server channel cases are selected', () => { + const result = runScript( + ['--include-server-channel', '--require-independent-targets', '--case', 'server-channel-agent-images-sse'], + { + OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1', + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key' + } + ); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /secret-server-channel-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.independent_targets.required_count, 5); + assert.deepEqual(report.independent_targets.selected_cases, []); + assert.equal(report.independent_targets.unselected_required_count, 5); + assert.deepEqual(report.independent_targets.unselected_required_cases, independentSmokeCaseIds()); + assert.deepEqual(report.unselected_required_cases, independentSmokeCaseIds()); + assert.equal(report.missing_required_count, 5); + assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds()); + assert.equal('skipped_required_cases' in report, false); + }); + + it('can dry-run the current server channel Agent Responses SSE smoke case without leaking keys', () => { + const result = runScript(['--include-server-channel', '--case', 'server-channel-agent-responses-sse'], { + OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1', + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', + IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-4.1' + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /secret-server-channel-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results.length, 1); + assert.equal(report.results[0].reason, 'requires --allow-billable'); + assert.equal(report.results[0].server_channel, true); + assert.equal(report.results[0].upstream_host, 'server-channel.example'); + }); + + it('can dry-run the current server channel Responses JSON smoke case without leaking keys', () => { + const result = runScript(['--include-server-channel', '--case', 'server-channel-responses-json'], { + OPENAI_CHANNEL_1_BASE_URL: 'https://server-channel.example/v1', + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', + IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-4.1' + }); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.doesNotMatch(result.stdout, /secret-server-channel-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results.length, 1); + assert.equal(report.results[0].reason, 'requires --allow-billable'); + assert.equal(report.results[0].server_channel, true); + assert.equal(report.results[0].upstream_host, 'server-channel.example'); + }); + + it('requires an explicit Responses top-level model for Responses smoke readiness', () => { + const result = runScript(['--case', 'sub2api-responses-json'], { + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: 'https://responses.example/v1', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'secret-responses-key' + }); + + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /secret-responses-key|responses\.example/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.results[0].reason, 'missing responses model env'); + assert.deepEqual(report.results[0].missing_env_any, [ + ['IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL', 'OPENAI_RESPONSES_API_MODEL'] + ]); + assert.equal(report.independent_targets.configuration_complete, false); + assert.deepEqual(report.independent_targets.missing_cases, ['sub2api-responses-json']); + }); + + it('does not treat the sub2api Responses image model env as readiness for the Responses top-level model', () => { + const result = runScript(['--case', 'sub2api-responses-json'], { + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: 'https://responses.example/v1', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'secret-responses-key', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_MODEL: 'gpt-image-2' + }); + + assert.equal(result.status, 0); + assert.doesNotMatch(result.stdout, /secret-responses-key|responses\.example/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.results[0].reason, 'missing responses model env'); + assert.deepEqual(report.results[0].missing_env_any, [ + ['IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL', 'OPENAI_RESPONSES_API_MODEL'] + ]); + assert.equal(report.independent_targets.configuration_complete, false); + assert.deepEqual(report.independent_targets.missing_cases, ['sub2api-responses-json']); + }); + + it('passes APP_PASSWORD auth to in-process page route billable smoke runs', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync(['--allow-billable', '--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key', + APP_PASSWORD: 'page-access-code' + }); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key|page-access-code/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].status, 200); + assert.equal(report.results[0].image_count, 1); + assert.deepEqual(upstream.calls, ['/v1/images/generations']); + } finally { + await upstream.close(); + } + }); + + it('pins page route smoke backend and strategy against global route defaults', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync(['--allow-billable', '--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key', + IMAGE_GENERATION_BACKEND: 'responses-image-generation', + IMAGE_STREAMING_STRATEGY: 'force-sse', + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-4.1-env' + }); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key|gpt-4\.1-env/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].status, 200); + assert.equal(report.results[0].content_type, 'application/json'); + assert.equal(report.results[0].image_count, 1); + assert.deepEqual(upstream.calls, ['/v1/images/generations']); + } finally { + await upstream.close(); + } + }); + + it('trims APP_PASSWORD before hashing page route billable smoke auth', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync(['--allow-billable', '--case', 'original-images-json'], { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key', + APP_PASSWORD: ' page-access-code ' + }); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key|page-access-code/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].status, 200); + assert.equal(report.results[0].image_count, 1); + assert.deepEqual(upstream.calls, ['/v1/images/generations']); + } finally { + await upstream.close(); + } + }); + + it('passes AGENT_API_TOKEN auth to in-process Agent route billable smoke runs', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync( + ['--include-server-channel', '--allow-billable', '--case', 'server-channel-agent-images-sse'], + { + OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', + AGENT_API_TOKEN: 'secret-agent-token' + } + ); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-server-channel-key|secret-agent-token/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].status, 200); + assert.equal(report.results[0].image_count, 1); + assert.deepEqual(upstream.calls, ['/v1/images/generations']); + } finally { + await upstream.close(); + } + }); + + it('passes APP_PASSWORD auth to in-process Agent route billable smoke runs when no Agent token is configured', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync( + ['--include-server-channel', '--allow-billable', '--case', 'server-channel-agent-images-sse'], + { + OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', + APP_PASSWORD: 'page-access-code' + } + ); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-server-channel-key|page-access-code/); + const report = JSON.parse(result.stdout); + assert.equal(report.results[0].status, 200); + assert.equal(report.results[0].image_count, 1); + assert.deepEqual(upstream.calls, ['/v1/images/generations']); + } finally { + await upstream.close(); + } + }); + + it('removes generated .real-smoke artifact files after billable local Agent smoke runs', async () => { + const upstream = await startLocalImagesSseUpstream(); + const before = listRealSmokeFiles(); + let after = before; + try { + const result = await runScriptAsync( + ['--include-server-channel', '--allow-billable', '--case', 'server-channel-agent-images-sse'], + { + OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key' + } + ); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-server-channel-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results[0].image_count, 1); + after = listRealSmokeFiles(); + assert.deepEqual(diffFiles(before, after), []); + } finally { + for (const file of diffFiles(before, after)) { + rmSync(join(realSmokeOutputDir, file), { force: true }); + } + await upstream.close(); + } + }); + + it('runs the current server channel Responses JSON smoke case against a local upstream', async () => { + const upstream = await startLocalResponsesJsonUpstream(); + try { + const result = await runScriptAsync( + ['--include-server-channel', '--allow-billable', '--case', 'server-channel-responses-json'], + { + OPENAI_CHANNEL_1_BASE_URL: upstream.baseUrl, + OPENAI_CHANNEL_1_API_KEYS: 'secret-server-channel-key', + IMAGE_REAL_SMOKE_SERVER_RESPONSES_MODEL: 'gpt-5.4' + } + ); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-server-channel-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results[0].status, 200); + assert.equal(report.results[0].image_count, 1); + assert.equal(report.results[0].first_b64_length, pngBase64.length); + } finally { + await upstream.close(); + } + }); + + it('marks the final gate satisfied only after every independent target runs successfully', async () => { + const upstream = await startLocalImageAndResponsesUpstream(); + try { + const result = await runScriptAsync( + ['--allow-billable', '--require-independent-targets'], + buildAllIndependentTargetEnv(upstream.baseUrl) + ); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-independent-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, true); + assert.equal(report.final_gate_satisfied, true); + assert.equal(report.independent_targets.configuration_complete, true); + assert.equal(report.independent_targets.configured_count, 5); + assert.equal(report.results.length, 5); + assert.equal( + report.results.every((item) => item.ok === true && item.skipped !== true), + true + ); + assert.equal('missing_required_cases' in report, false); + } finally { + await upstream.close(); + } + }); + + it('fails the final gate when a page SSE smoke case returns a stream error event', async () => { + const successUpstream = await startLocalImageAndResponsesUpstream(); + const failingSseUpstream = await startLocalFailingImagesSseUpstream(); + try { + const result = await runScriptAsync(['--allow-billable', '--require-independent-targets'], { + ...buildAllIndependentTargetEnv(successUpstream.baseUrl), + IMAGE_REAL_SMOKE_GAOREN_BASE_URL: failingSseUpstream.baseUrl, + IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'secret-independent-key-gaoren' + }); + + assert.equal(result.status, 1, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-independent-key/); + const report = JSON.parse(result.stdout); + const failedCase = report.results.find((item) => item.id === 'gaoren-images-sse'); + + assert.equal(report.ok, false); + assert.equal(report.final_gate_satisfied, false); + assert.equal(failedCase?.ok, false); + assert.equal(failedCase?.status, 200); + assert.equal(failedCase?.done_image_count, 0); + assert.match(failedCase?.error || '', /b64_json|最终图片/); + assert.equal(successUpstream.calls.length, 4); + } finally { + await successUpstream.close(); + await failingSseUpstream.close(); + } + }); + + it('exits after its own timeout when a billable upstream never responds', async () => { + const upstream = await startLocalHangingImageUpstream(); + try { + const result = await runScriptAsync( + ['--allow-billable', '--case', 'original-images-json', '--timeout-ms', '1000'], + { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: upstream.baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-real-smoke-key' + }, + { killAfterMs: 4500 } + ); + + assert.equal(result.signal, null, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.equal(result.killedByTest, false, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.equal(result.status, 1, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.doesNotMatch(result.stdout, /secret-real-smoke-key/); + const report = JSON.parse(result.stdout); + assert.equal(report.ok, false); + assert.equal(report.final_gate_satisfied, false); + assert.equal(report.results[0].timed_out, true); + assert.equal(report.results[0].ok, false); + assert.match(report.results[0].error, /timed out after 1000ms/); + } finally { + await upstream.close(); + } + }); + + it('rejects invalid timeout values before running smoke cases', () => { + const result = runScript(['--timeout-ms', '999']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--timeout-ms 必须是不小于 1000 的整数毫秒/); + }); + + it('rejects missing env file values before running smoke cases', () => { + const result = runScript(['--env-file']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--env-file 缺少参数值/); + }); + + it('lets the npm smoke script pass --env-file through to the smoke script', () => { + const missingEnvFilePath = join(repoRoot, 'generated-images/.missing-real-smoke.env'); + rmSync(missingEnvFilePath, { force: true }); + + const result = spawnSync('npm', ['run', 'smoke:image-upstream-real', '--', '--env-file', missingEnvFilePath], { + cwd: repoRoot, + encoding: 'utf8', + env: buildScriptEnv() + }); + + assert.equal(result.status, 1); + assert.match(result.stderr, /--env-file 指定的文件不存在/); + assert.doesNotMatch(result.stderr, /node: .*not found/); + assert.doesNotMatch(result.stderr, /ModuleJob\.run|Node\.js v|at loadEnvFile/); + }); + + it('lets the npm final gate report readiness when the optional env file is absent', () => { + const missingEnvFilePath = join(repoRoot, 'generated-images/.missing-real-smoke.env'); + rmSync(missingEnvFilePath, { force: true }); + + const result = spawnSync( + 'npm', + [ + 'run', + 'smoke:image-upstream-real', + '--', + '--env-file-if-exists', + missingEnvFilePath, + '--require-independent-targets', + '--allow-billable' + ], + { + cwd: repoRoot, + encoding: 'utf8', + env: buildScriptEnv() + } + ); + + assert.equal(result.status, 1); + assert.equal(result.stderr.trim(), ''); + const report = JSON.parse(result.stdout.slice(result.stdout.indexOf('{'))); + assert.equal(report.independent_targets.configuration_complete, false); + assert.equal(report.missing_required_count, 5); + assert.deepEqual(report.missing_required_cases, independentSmokeCaseIds()); + }); + + it('fails explicitly for unknown real upstream smoke cases', () => { + const result = runScript(['--case', 'missing-case']); + + assert.equal(result.status, 1); + assert.match(result.stderr, /未知真实 smoke 场景:missing-case/); + }); + + it('lists every independent real upstream smoke target in help output', () => { + const result = runScript(['--help']); + + assert.equal(result.status, 0); + assert.equal(result.stderr.trim(), ''); + assert.match(result.stdout, /--env-file /); + assert.match(result.stdout, /--env-file-if-exists /); + for (const prefix of independentSmokePrefixes()) { + assert.match(result.stdout, new RegExp(`${prefix}_BASE_URL / ${prefix}_API_KEY`)); + } + }); + + it('documents every independent real upstream smoke target in env templates without secrets', () => { + const envExample = readFileSync(join(repoRoot, '.env.example'), 'utf8'); + const realSmokeEnvExample = readFileSync(join(repoRoot, '.env.real-smoke.example'), 'utf8'); + const gitignore = readFileSync(join(repoRoot, '.gitignore'), 'utf8'); + + for (const prefix of independentSmokePrefixes()) { + assert.match(envExample, new RegExp(`# ${prefix}_BASE_URL=`)); + assert.match(envExample, new RegExp(`# ${prefix}_API_KEY=$`, 'm')); + assert.match(realSmokeEnvExample, new RegExp(`${prefix}_BASE_URL=`)); + assert.match(realSmokeEnvExample, new RegExp(`${prefix}_API_KEY=$`, 'm')); + } + assert.match(envExample, /# IMAGE_REAL_SMOKE_TIMEOUT_MS=240000/); + assert.match(realSmokeEnvExample, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m); + assert.match(gitignore, /^!.env\.real-smoke\.example$/m); + }); +}); + +function independentSmokePrefixes() { + return [ + 'IMAGE_REAL_SMOKE_ORIGINAL', + 'IMAGE_REAL_SMOKE_GAOREN', + 'IMAGE_REAL_SMOKE_SUB2API', + 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES', + 'IMAGE_REAL_SMOKE_GPT2IMAGE' + ]; +} + +function independentSmokeCaseIds() { + return [ + 'original-images-json', + 'gaoren-images-sse', + 'sub2api-images-sse', + 'sub2api-responses-json', + 'gpt2image-responses-sse' + ]; +} + +function buildAllIndependentTargetEnv(baseUrl) { + return { + IMAGE_REAL_SMOKE_ORIGINAL_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_ORIGINAL_API_KEY: 'secret-independent-key-original', + IMAGE_REAL_SMOKE_GAOREN_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_GAOREN_API_KEY: 'secret-independent-key-gaoren', + IMAGE_REAL_SMOKE_SUB2API_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_SUB2API_API_KEY: 'secret-independent-key-sub2api', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_API_KEY: 'secret-independent-key-sub2api-responses', + IMAGE_REAL_SMOKE_SUB2API_RESPONSES_RESPONSES_MODEL: 'gpt-4.1', + IMAGE_REAL_SMOKE_GPT2IMAGE_BASE_URL: baseUrl, + IMAGE_REAL_SMOKE_GPT2IMAGE_API_KEY: 'secret-independent-key-gpt2image', + IMAGE_REAL_SMOKE_GPT2IMAGE_RESPONSES_MODEL: 'gpt-5.4' + }; +} + +function runScript(args = [], env = {}) { + return spawnSync(process.execPath, ['--import', 'tsx', scriptPath, ...args], { + cwd: repoRoot, + encoding: 'utf8', + env: buildScriptEnv(env) + }); +} + +function runScriptAsync(args = [], env = {}, options = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, ['--import', 'tsx', scriptPath, ...args], { + cwd: repoRoot, + env: buildScriptEnv(env), + stdio: ['ignore', 'pipe', 'pipe'] + }); + const startedAt = Date.now(); + let stdout = ''; + let stderr = ''; + let killedByTest = false; + const killTimer = options.killAfterMs + ? setTimeout(() => { + killedByTest = true; + child.kill('SIGTERM'); + }, options.killAfterMs) + : undefined; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk) => { + stderr += chunk; + }); + child.on('close', (status, signal) => { + if (killTimer) clearTimeout(killTimer); + resolve({ status, signal, stdout, stderr, killedByTest, elapsedMs: Date.now() - startedAt }); + }); + }); +} + +function listRealSmokeFiles() { + try { + return readdirSync(realSmokeOutputDir) + .filter((item) => item.endsWith('.png') || item.endsWith('.jpeg')) + .sort(); + } catch { + return []; + } +} + +function diffFiles(before, after) { + const beforeSet = new Set(before); + return after.filter((file) => !beforeSet.has(file)); +} + +function writeEnvFile(filepath, content) { + rmSync(filepath, { force: true }); + mkdirSync(dirname(filepath), { recursive: true }); + writeFileSync(filepath, `${content}\n`, 'utf8'); +} + +function buildScriptEnv(env = {}) { + const scriptEnv = { ...process.env }; + for (const key of Object.keys(scriptEnv)) { + if (isSmokeEnvKey(key)) delete scriptEnv[key]; + } + return { + ...scriptEnv, + ...env, + NODE_ENV: 'test', + IMAGE_REAL_SMOKE_SKIP_DOTENV: '1' + }; +} + +function isSmokeEnvKey(key) { + return ( + key.startsWith('IMAGE_REAL_SMOKE_') || + key.startsWith('OPENAI_CHANNEL_') || + key === 'OPENAI_API_BASE_URL' || + key === 'OPENAI_API_KEY' || + key === 'OPENAI_RESPONSES_API_MODEL' || + key === 'OPENAI_ROUTING_STRATEGY' || + key === 'OPENAI_CHANNELS_JSON' || + key === 'IMAGE_GENERATION_BACKEND' || + key === 'IMAGE_STREAMING_STRATEGY' || + key === 'ENABLE_RESPONSES_IMAGE_BACKEND' || + key === 'IMAGE_OUTPUT_DIR' || + key === 'APP_PASSWORD' || + key === 'AGENT_API_TOKEN' + ); +} + +async function startLocalImagesSseUpstream() { + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + request.resume(); + await new Promise((resolve) => request.on('end', resolve)); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write( + `event: image_generation.completed\ndata: ${JSON.stringify({ type: 'image_generation.completed', b64_json: pngBase64 })}\n\n` + ); + response.write('data: [DONE]\n\n'); + response.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} + +async function startLocalFailingImagesSseUpstream() { + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + request.resume(); + await new Promise((resolve) => request.on('end', resolve)); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write( + `event: image_generation.completed\ndata: ${JSON.stringify({ type: 'image_generation.completed' })}\n\n` + ); + response.write('data: [DONE]\n\n'); + response.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} + +async function startLocalHangingImageUpstream() { + const sockets = new Set(); + const server = createServer(async (request) => { + if (request.method === 'POST' && request.url?.endsWith('/images/generations')) { + request.resume(); + return; + } + request.resume(); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => + new Promise((resolve, reject) => { + for (const socket of sockets) socket.destroy(); + server.close((error) => (error ? reject(error) : resolve())); + }) + }; +} + +async function startLocalImageAndResponsesUpstream() { + const calls = []; + const server = createServer(async (request, response) => { + if (request.method !== 'POST') { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + calls.push(request.url); + const body = await readRequestBody(request); + if (request.url?.endsWith('/images/generations')) { + respondToImageGenerationRequest(response, body); + return; + } + if (request.url?.endsWith('/responses')) { + respondToResponsesImageRequest(response, body); + return; + } + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + calls, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} + +async function readRequestBody(request) { + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + await new Promise((resolve) => request.on('end', resolve)); + return body ? JSON.parse(body) : {}; +} + +function respondToImageGenerationRequest(response, body) { + if (body.stream === true) { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write( + `event: image_generation.completed\ndata: ${JSON.stringify({ type: 'image_generation.completed', b64_json: pngBase64 })}\n\n` + ); + response.write('data: [DONE]\n\n'); + response.end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ data: [{ b64_json: pngBase64 }] })); +} + +function respondToResponsesImageRequest(response, body) { + if (body.tool_choice?.type !== 'image_generation' || body.tools?.[0]?.type !== 'image_generation') { + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'invalid responses image request' } })); + return; + } + if (body.stream === true) { + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + response.write( + `event: response.output_item.done\ndata: ${JSON.stringify({ + type: 'response.output_item.done', + item: { type: 'image_generation_call', status: 'completed', result: pngBase64 } + })}\n\n` + ); + response.write('data: [DONE]\n\n'); + response.end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ output: [{ type: 'image_generation_call', status: 'completed', result: pngBase64 }] }) + ); +} + +async function startLocalResponsesJsonUpstream() { + const server = createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + let body = ''; + request.setEncoding('utf8'); + request.on('data', (chunk) => { + body += chunk; + }); + await new Promise((resolve) => request.on('end', resolve)); + const parsed = JSON.parse(body); + if ( + parsed.stream !== false || + parsed.tool_choice?.type !== 'image_generation' || + parsed.tools?.[0]?.type !== 'image_generation' + ) { + response.writeHead(400, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'invalid responses image request' } })); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify({ + output: [{ type: 'image_generation_call', status: 'completed', result: pngBase64 }] + }) + ); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} diff --git a/scripts/status.mjs b/scripts/status.mjs new file mode 100644 index 0000000000000000000000000000000000000000..effd47e9097c84f958460f81e3e2844bb4e4bcd6 --- /dev/null +++ b/scripts/status.mjs @@ -0,0 +1,263 @@ +#!/usr/bin/env node + +import { existsSync, readFileSync } from 'node:fs'; + +import { HF_SPACE_ID, HF_SPACE_URL } from './hf-space-doctor-utils.mjs'; +import { isMainModule, parseJsonPayload, printJson, runCommand, runCommandStrict } from './command-center-utils.mjs'; + +const REMOTE_STATUS_TIMEOUT_MS = 30_000; +const IMAGE_UPSTREAM_REAL_SMOKE_CASES = [ + { id: 'original-images-json', prefix: 'IMAGE_REAL_SMOKE_ORIGINAL' }, + { id: 'gaoren-images-sse', prefix: 'IMAGE_REAL_SMOKE_GAOREN' }, + { id: 'sub2api-images-sse', prefix: 'IMAGE_REAL_SMOKE_SUB2API' }, + { + id: 'sub2api-responses-json', + prefix: 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES', + fallbackPrefix: 'IMAGE_REAL_SMOKE_SUB2API', + requiresResponsesModel: true + }, + { id: 'gpt2image-responses-sse', prefix: 'IMAGE_REAL_SMOKE_GPT2IMAGE', requiresResponsesModel: true } +]; +const IMAGE_UPSTREAM_FINAL_GATE_COMMAND = + 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable'; +const STATUS_ENV_FILES = [ + { path: '.env.local', override: false }, + { path: '.env.real-smoke.local', override: true } +]; + +export function buildAdminCommands() { + return { + doctor: 'npm run doctor', + status: 'npm run status', + verify: 'npm run verify', + deploy_local: 'npm run deploy:local', + deploy_space: 'npm run deploy:space', + agent_doctor: 'npm run agent:doctor', + hf_space_doctor: 'npm run doctor:hf-space', + hf_space_smoke: 'npm run smoke:hf-space' + }; +} + +export function parseGitStatusEntries(output) { + const entries = output.split('\0').filter(Boolean); + const paths = []; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + const status = entry.slice(0, 2); + paths.push(entry.slice(3)); + if (status.includes('R') || status.includes('C')) index += 1; + } + return paths; +} + +function readEnv(env, key) { + const value = env[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +} + +function shouldSetStatusEnv(key, baseEnv, statusEnv, options) { + if (baseEnv[key] !== undefined) return false; + return options.override || statusEnv[key] === undefined; +} + +function loadStatusEnvFile(statusEnv, baseEnv, options) { + if (!existsSync(options.path)) return; + for (const line of readFileSync(options.path, 'utf8').split(/\r?\n/)) { + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || !shouldSetStatusEnv(match[1], baseEnv, statusEnv, options)) continue; + const value = match[2].trim(); + const quoted = + value.length >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))); + statusEnv[match[1]] = quoted ? value.slice(1, -1) : value; + } +} + +export function readStatusEnvFromFiles(baseEnv = process.env, envFiles = STATUS_ENV_FILES) { + const statusEnv = { ...baseEnv }; + for (const envFile of envFiles) { + loadStatusEnvFile(statusEnv, baseEnv, envFile); + } + return statusEnv; +} + +function readSmokeEnvAlternatives(testCase, suffix) { + const keys = [`${testCase.prefix}_${suffix}`]; + if (testCase.fallbackPrefix && testCase.fallbackPrefix !== testCase.prefix) { + keys.push(`${testCase.fallbackPrefix}_${suffix}`); + } + return keys; +} + +function readResponsesModelEnvAlternatives(testCase) { + return [`${testCase.prefix}_RESPONSES_MODEL`, 'OPENAI_RESPONSES_API_MODEL']; +} + +function readFirstStatusEnv(env, keys) { + for (const key of keys) { + const value = readEnv(env, key); + if (value) return { key, value }; + } + return undefined; +} + +function readBaseUrlValidationError(value) { + let url; + try { + url = new URL(value); + } catch { + return 'must_be_http_or_https_absolute_url'; + } + if (url.protocol !== 'http:' && url.protocol !== 'https:') return 'must_use_http_or_https'; + if (url.username || url.password) return 'must_not_include_credentials'; + if (url.search || url.hash) return 'must_not_include_query_or_fragment'; + return undefined; +} + +function readTargetConfigured(testCase, env) { + const baseUrl = readFirstStatusEnv(env, readSmokeEnvAlternatives(testCase, 'BASE_URL')); + const apiKey = readFirstStatusEnv(env, readSmokeEnvAlternatives(testCase, 'API_KEY')); + const responsesModel = testCase.requiresResponsesModel + ? readFirstStatusEnv(env, readResponsesModelEnvAlternatives(testCase)) + : undefined; + const baseUrlError = baseUrl ? readBaseUrlValidationError(baseUrl.value) : undefined; + return { + baseUrl: Boolean(baseUrl), + apiKey: Boolean(apiKey), + responsesModel: Boolean(responsesModel), + invalidEnv: baseUrlError ? [{ key: baseUrl.key, reason: baseUrlError }] : [] + }; +} + +function readMissingEnvAny(testCase, target) { + const groups = []; + if (!target.baseUrl) groups.push(readSmokeEnvAlternatives(testCase, 'BASE_URL')); + if (target.baseUrl && !target.apiKey) groups.push(readSmokeEnvAlternatives(testCase, 'API_KEY')); + if (target.baseUrl && testCase.requiresResponsesModel && !target.responsesModel) { + groups.push(readResponsesModelEnvAlternatives(testCase)); + } + return groups; +} + +export function buildImageUpstreamRealSmokeStatus(env = process.env) { + const caseSummaries = IMAGE_UPSTREAM_REAL_SMOKE_CASES.map((testCase) => { + const target = readTargetConfigured(testCase, env); + const missingEnvAny = readMissingEnvAny(testCase, target); + return { + id: testCase.id, + configured: missingEnvAny.length === 0 && target.invalidEnv.length === 0, + ...(missingEnvAny.length > 0 ? { missing_env_any: missingEnvAny } : {}), + ...(target.invalidEnv.length > 0 ? { invalid_env: target.invalidEnv } : {}) + }; + }); + const configuredCases = caseSummaries.filter((item) => item.configured).map((item) => item.id); + const missingCases = caseSummaries.filter( + (item) => Array.isArray(item.missing_env_any) && item.missing_env_any.length > 0 + ); + const invalidCases = caseSummaries.filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0); + return { + required_count: IMAGE_UPSTREAM_REAL_SMOKE_CASES.length, + required_cases: IMAGE_UPSTREAM_REAL_SMOKE_CASES.map((testCase) => testCase.id), + configuration_complete: missingCases.length === 0 && invalidCases.length === 0, + configured_count: configuredCases.length, + configured_cases: configuredCases, + missing_count: missingCases.length, + missing_cases: missingCases.map((item) => item.id), + missing_env_any: Object.fromEntries(missingCases.map((item) => [item.id, item.missing_env_any])), + invalid_count: invalidCases.length, + invalid_cases: invalidCases.map((item) => item.id), + invalid_env: Object.fromEntries(invalidCases.map((item) => [item.id, item.invalid_env])), + final_gate_command: IMAGE_UPSTREAM_FINAL_GATE_COMMAND + }; +} + +function parseArgs(argv) { + const unknown = argv.find((arg) => !['--help', '-h', '--remote'].includes(arg)); + if (unknown) throw new Error(`Unknown option: ${unknown}`); + return { + help: argv.includes('--help') || argv.includes('-h'), + remote: argv.includes('--remote') + }; +} + +function printHelp() { + console.log(`Usage: + npm run status + npm run status -- --remote + +Options: + --remote Include read-only Hugging Face Space runtime info. + --help Show this help.`); +} + +function buildLocalStatus() { + const packageJson = JSON.parse(readFileSync('package.json', 'utf8')); + const branch = runCommandStrict('git', ['branch', '--show-current']).trim(); + const head = runCommandStrict('git', ['rev-parse', '--short', 'HEAD']).trim(); + const changed = parseGitStatusEntries(runCommandStrict('git', ['status', '--porcelain=v1', '-z'])); + return { + product: packageJson.name, + version: packageJson.version, + branch, + head, + dirty: changed.length > 0, + changed_files: changed, + node: process.version, + commands: buildAdminCommands(), + space: { + id: HF_SPACE_ID, + url: HF_SPACE_URL + }, + agent: { + capabilities: '/api/agent/capabilities', + skill: 'skills/gpt-image-playground-agent/SKILL.md' + }, + image_upstream_real_smoke: buildImageUpstreamRealSmokeStatus(readStatusEnvFromFiles(process.env)) + }; +} + +export function readRemoteStatusFromResult(result) { + if (!result.ok) { + return { ok: false, error: result.error || result.stderr || result.stdout || 'Cannot read Hugging Face Space info.' }; + } + let info; + try { + info = parseJsonPayload(result.stdout, 'hf spaces info'); + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) }; + } + return { + ok: true, + stage: info.runtime?.stage || 'unknown', + sha: info.sha || info.runtime?.raw?.sha || 'unknown', + hardware: info.runtime?.hardware || info.hardware || 'unknown' + }; +} + +function readRemoteStatus() { + return readRemoteStatusFromResult( + runCommand('hf', ['spaces', 'info', HF_SPACE_ID, '--format', 'json'], { timeoutMs: REMOTE_STATUS_TIMEOUT_MS }) + ); +} + +function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + return; + } + + const status = buildLocalStatus(); + printJson({ + ok: true, + ...status, + ...(options.remote ? { remote: readRemoteStatus() } : {}) + }); +} + +try { + if (isMainModule(import.meta.url, process.argv[1])) main(); +} catch (error) { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +} diff --git a/scripts/test-postgres-live.mjs b/scripts/test-postgres-live.mjs index e3cacccd14d95ed60ccce93eeeb5b5b9b34dba53..830a2360e59fe878c185a5c0445a82b6b55cc91c 100644 --- a/scripts/test-postgres-live.mjs +++ b/scripts/test-postgres-live.mjs @@ -6,9 +6,17 @@ const testFiles = ['src/lib/agent-state-postgres.test.ts', 'src/app/api/agent/ag const POSTGRES_READY_ATTEMPTS = 30; const POSTGRES_READY_INTERVAL_MS = 1000; +class CommandFailedError extends Error { + constructor(command, args, status, stderr = '') { + super(`${command} ${args.join(' ')} failed with exit code ${status}`); + this.status = status; + this.stderr = stderr; + } +} + function run(command, args, options = {}) { const result = spawnSync(command, args, { - stdio: options.capture ? ['ignore', 'pipe', 'pipe'] : options.silent ? 'ignore' : 'inherit', + stdio: options.capture || options.silent ? ['ignore', 'pipe', 'pipe'] : 'inherit', encoding: 'utf8', env: options.env || process.env }); @@ -16,7 +24,7 @@ function run(command, args, options = {}) { return result; } if (result.status !== 0) { - process.exit(result.status || 1); + throw new CommandFailedError(command, args, result.status || 1, result.stderr || ''); } return result; } @@ -31,6 +39,10 @@ function runTests(databaseUrl) { }); } +function statusFromError(error) { + return error instanceof CommandFailedError ? error.status : 1; +} + async function waitForPostgres(containerName) { for (let attempt = 0; attempt < POSTGRES_READY_ATTEMPTS; attempt += 1) { const result = spawnSync('docker', ['exec', containerName, 'pg_isready', '-U', 'agent_test', '-d', 'agent_test'], { @@ -56,7 +68,11 @@ function readMappedPort(containerName) { } if (process.env.AGENT_POSTGRES_TEST_DATABASE_URL) { - runTests(process.env.AGENT_POSTGRES_TEST_DATABASE_URL); + try { + runTests(process.env.AGENT_POSTGRES_TEST_DATABASE_URL); + } catch (error) { + process.exit(statusFromError(error)); + } process.exit(0); } @@ -81,6 +97,15 @@ try { await waitForPostgres(containerName); const port = readMappedPort(containerName); runTests(`postgres://agent_test:agent_test@127.0.0.1:${port}/agent_test`); +} catch (error) { + process.exitCode = statusFromError(error); + if (error instanceof CommandFailedError) { + if (error.stderr.trim()) { + console.error(error.stderr.trim()); + } + } else { + console.error(error instanceof Error ? error.message : String(error)); + } } finally { spawnSync('docker', ['rm', '-f', containerName], { stdio: 'ignore' }); } diff --git a/scripts/test-postgres-live.test.mjs b/scripts/test-postgres-live.test.mjs new file mode 100644 index 0000000000000000000000000000000000000000..503cf32dff683dcaa6cd0edd5533389d1c32a33a --- /dev/null +++ b/scripts/test-postgres-live.test.mjs @@ -0,0 +1,143 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { describe, it } from 'node:test'; + +const repoRoot = fileURLToPath(new URL('..', import.meta.url)); +const scriptPath = fileURLToPath(new URL('./test-postgres-live.mjs', import.meta.url)); + +describe('live PostgreSQL test launcher cleanup', () => { + it('removes the temporary PostgreSQL container when the test command fails', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'pg-live-launcher-')); + const logPath = path.join(tempDir, 'docker.log'); + const binDir = path.join(tempDir, 'bin'); + await mkdir(binDir); + await writeFile(path.join(binDir, 'node'), buildFailingNodeShim(), { mode: 0o755 }); + await writeFile(path.join(binDir, 'docker'), buildDockerShim(logPath), { mode: 0o755 }); + + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + } + }); + + assert.equal(result.status, 37, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + const dockerLog = await readFile(logPath, 'utf8'); + assert.match(dockerLog, /^run /m); + assert.match(dockerLog, /^exec /m); + assert.match(dockerLog, /^port /m); + assert.match(dockerLog, /^rm -f gpt-image-agent-test-pg-/m); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('preserves the test exit code when a PostgreSQL URL is provided', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'pg-live-launcher-')); + const logPath = path.join(tempDir, 'docker.log'); + const binDir = path.join(tempDir, 'bin'); + await mkdir(binDir); + await writeFile(path.join(binDir, 'node'), buildFailingNodeShim(), { mode: 0o755 }); + await writeFile(path.join(binDir, 'docker'), buildDockerShim(logPath), { mode: 0o755 }); + + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + AGENT_POSTGRES_TEST_DATABASE_URL: 'postgres://agent_test:agent_test@127.0.0.1:55432/agent_test', + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + } + }); + + assert.equal(result.status, 37, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + await assert.rejects(readFile(logPath, 'utf8'), { code: 'ENOENT' }); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); + + it('surfaces Docker startup stderr when the temporary container cannot start', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'pg-live-launcher-')); + const logPath = path.join(tempDir, 'docker.log'); + const binDir = path.join(tempDir, 'bin'); + await mkdir(binDir); + await writeFile(path.join(binDir, 'node'), buildFailingNodeShim(), { mode: 0o755 }); + await writeFile(path.join(binDir, 'docker'), buildFailingDockerRunShim(logPath), { mode: 0o755 }); + + try { + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH || ''}` + } + }); + + assert.equal(result.status, 42, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + assert.match(result.stderr, /docker daemon unavailable/); + const dockerLog = await readFile(logPath, 'utf8'); + assert.match(dockerLog, /^run /m); + assert.match(dockerLog, /^rm -f gpt-image-agent-test-pg-/m); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); + +function buildFailingNodeShim() { + return `#!/bin/sh +exit 37 +`; +} + +function buildDockerShim(logPath) { + return `#!/bin/sh +printf '%s\\n' "$*" >> ${JSON.stringify(logPath)} +case "$1" in + run) + exit 0 + ;; + exec) + exit 0 + ;; + port) + printf '127.0.0.1:55432\\n' + exit 0 + ;; + rm) + exit 0 + ;; + *) + exit 2 + ;; +esac +`; +} + +function buildFailingDockerRunShim(logPath) { + return `#!/bin/sh +printf '%s\\n' "$*" >> ${JSON.stringify(logPath)} +case "$1" in + run) + printf 'docker daemon unavailable\\n' >&2 + exit 42 + ;; + rm) + exit 0 + ;; + *) + exit 2 + ;; +esac +`; +} diff --git a/scripts/verify.mjs b/scripts/verify.mjs new file mode 100644 index 0000000000000000000000000000000000000000..c8ed4fe2748d59e5eb0813b7540cbd2b20781075 --- /dev/null +++ b/scripts/verify.mjs @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +import { isMainModule, pickFailureOutput, printJson, runCommand } from './command-center-utils.mjs'; + +const FULL_VERIFY_PLAN = [ + { name: 'test', command: 'npm', args: ['test'] }, + { name: 'lint', command: 'npm', args: ['run', 'lint'] }, + { name: 'lint:scripts', command: 'npm', args: ['run', 'lint:scripts'] }, + { name: 'build', command: 'npm', args: ['run', 'build'] }, + { name: 'diff-check', command: 'git', args: ['diff', '--check'] }, + { name: 'diff-cached-check', command: 'git', args: ['diff', '--cached', '--check'] } +]; + +const QUICK_VERIFY_PLAN = [ + { name: 'test:scripts', command: 'npm', args: ['run', 'test:scripts'] }, + { name: 'lint:scripts', command: 'npm', args: ['run', 'lint:scripts'] }, + { name: 'diff-check', command: 'git', args: ['diff', '--check'] }, + { name: 'diff-cached-check', command: 'git', args: ['diff', '--cached', '--check'] } +]; + +const POSTGRES_VERIFY_STEP = { name: 'test:postgres', command: 'npm', args: ['run', 'test:postgres'] }; + +export function buildVerifyPlan(options = {}) { + const source = options.quick ? QUICK_VERIFY_PLAN : FULL_VERIFY_PLAN; + const plan = source.filter((step) => !(options.skipBuild && step.name === 'build')); + if (!options.postgres) return plan; + const firstDiffCheckIndex = plan.findIndex((step) => step.name.startsWith('diff-')); + if (firstDiffCheckIndex === -1) return [...plan, POSTGRES_VERIFY_STEP]; + return [...plan.slice(0, firstDiffCheckIndex), POSTGRES_VERIFY_STEP, ...plan.slice(firstDiffCheckIndex)]; +} + +function parseArgs(argv) { + const unknown = argv.find((arg) => !['--help', '-h', '--quick', '--skip-build', '--postgres'].includes(arg)); + if (unknown) throw new Error(`Unknown option: ${unknown}`); + return { + help: argv.includes('--help') || argv.includes('-h'), + postgres: argv.includes('--postgres'), + quick: argv.includes('--quick'), + skipBuild: argv.includes('--skip-build') + }; +} + +function printHelp() { + console.log(`Usage: + npm run verify + npm run verify -- --quick + +Options: + --quick Run script tests, script syntax checks, and git diff whitespace checks. + --postgres Include the live PostgreSQL gate with npm run test:postgres. + --skip-build Run the full plan without npm run build. + --help Show this help.`); +} + +function runVerify(options) { + const checks = []; + for (const step of buildVerifyPlan(options)) { + const result = runCommand(step.command, step.args); + checks.push({ + name: step.name, + command: [step.command, ...step.args].join(' '), + status: result.ok ? 'pass' : 'fail', + elapsed_ms: result.elapsed_ms, + ...(result.ok ? {} : { output: pickFailureOutput(result) }) + }); + if (!result.ok) break; + } + + const ok = checks.every((check) => check.status === 'pass'); + printJson({ ok, profile: options.quick ? 'quick' : 'full', postgres: Boolean(options.postgres), checks }); + if (!ok) process.exit(1); +} + +try { + if (isMainModule(import.meta.url, process.argv[1])) { + const options = parseArgs(process.argv.slice(2)); + if (options.help) { + printHelp(); + } else { + runVerify(options); + } + } +} catch (error) { + printJson({ ok: false, error: error instanceof Error ? error.message : String(error) }); + process.exit(1); +} diff --git a/skills/gpt-image-playground-agent/SKILL.md b/skills/gpt-image-playground-agent/SKILL.md index e76d3bb5f4f46141d71697b6eef386c9e72326d5..178ab714e3e9b4e0d4af2917a7c983439ac27a99 100644 --- a/skills/gpt-image-playground-agent/SKILL.md +++ b/skills/gpt-image-playground-agent/SKILL.md @@ -7,29 +7,40 @@ description: 当用户需要通过 API 调用已部署的 GPT Image Playground 通过用户已部署的 GPT Image Playground `/api/agent/*` 接口生成或编辑图片。不要假设服务一定在本机;不要模拟网页表单;直接使用 Agent API 契约、幂等键和产物 URL。 +## 路由硬规则 + +- 先读取 `GET /api/agent/capabilities` 的 `routing_rules`,按机器可读规则选择端点。 +- `edit` 且 `max(width,height)>2048` 时,必须使用页面端 `POST /api/images` form-data SSE 路径,不要走非流式 `/api/agent/images/edit`。 +- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE,并记录切换原因、失败清单和续跑锚点。 +- 长图恢复或需要续跑锚点的生产请求优先使用页面端 `POST /api/images` SSE,保留局部进度和缺最终图诊断。 +- 普通小图单次文生图使用 `/api/agent/images/generate`;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE,流式失败后先诊断,再显式选择 Agent JSON 或 job 路径,不自动回退。 +- 同一个已进入终态 `failed` 的 `Idempotency-Key` 只会回放失败;重新尝试必须先诊断原因,再创建新的业务操作和新的 key。 + ## 执行流程 1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。 2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。 -3. 读取 capabilities 中的认证方式、模型、模型级限制、Agent 流式边界、状态后端和端点路径;不要硬编码假设部署方式。 -4. 为每个业务操作生成稳定的 `Idempotency-Key`。同一操作重试时复用原 key;不同操作不要复用。 -5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点是非流式端点,当前固定以 `stream: false` 调上游。 +3. 读取 capabilities 中的认证方式、模型、模型级限制、`routing_rules`、Agent 流式边界、页面 SSE 鉴权、后端 runtime enablement、状态后端和端点路径;不要硬编码假设部署方式。 +4. 为每个业务操作生成稳定的 `Idempotency-Key`。网络中断、运行中轮询或非终态重试复用原 key;同一 key 已进入 `failed` 终态后不再用于触发新执行,必须先诊断原因,再创建新的业务操作和新的 key。 +5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点对外始终返回最终 `AgentImageResponse` JSON;如 capabilities 声明 `agent_streaming.upstream_sse.supported=true`,可通过 `image_backend`、`streaming_strategy`、`partial_images` 显式启用内部上游 SSE 消费。 6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。 7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。 -8. 不要把页面端 `POST /api/images` 当成 Agent 默认路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明。 -9. 读取 `agent_jobs`。若 `supported=true` 且 `mode=job_polling`,4K/high 或长耗时任务优先走 job/polling。 +8. 不要把页面端 `POST /api/images` 当成普通 Agent JSON 路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明;仅在 `routing_rules` 命中高分辨率 edit、大图单次文生图、复杂 UI 批量、长图恢复或明确诊断后切换。 +9. 读取 `agent_jobs`。job 路径只在显式选择时使用;`max_edge>2048` 的单次文生图默认优先走页面端 `/api/images` SSE。 10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。 11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。 ## 鉴权 -如果服务端配置了 `AGENT_API_TOKEN`,发送: +Agent JSON、Agent edit、job 和 artifact 端点的鉴权以 `auth.schemes` 为准。如果服务端配置了 `AGENT_API_TOKEN`,发送: ```text Authorization: Bearer ``` -此时服务端只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一鉴权方式。 +此时 Agent 端点只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,Agent 端点发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一 Agent 鉴权方式。 + +页面端 `/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`。 ## 调用约束 @@ -39,13 +50,14 @@ Authorization: Bearer - 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。 - 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。 - 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。 -- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 支持流式;Agent generate/edit 当前以 `non_streaming_only` 声明。 +- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。 - 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。 -- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。 +- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。 +- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。 ## Job Polling -当 `agent_jobs.supported=true` 时,长耗时文生图可使用: +当 `agent_jobs.supported=true` 时,显式 job 路径可使用: 1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。 2. `GET /api/agent/jobs/{id}` 轮询状态。 @@ -53,7 +65,7 @@ Authorization: Bearer `GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。 -当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。 +当前 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 不是默认路径。 ## 可用脚本 @@ -61,6 +73,17 @@ Authorization: Bearer - `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。 - `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。 +生成和编辑脚本的 dry-run 输出会包含 `routing_guidance`,用于在真实计费前检查当前请求应走 Agent JSON、页面 SSE,或在页面流式失败后先诊断再手动选定后续路径。 + +如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令: + +- `npm run status`:只读查看 git、Space 目标、Agent API、Skill 入口和独立真实图片上游 smoke 配置摘要;会自动读取 `.env.real-smoke.local`,不输出 URL 或 API Key。 +- `npm run doctor`:统一诊断本机与 HF Space 配置,不写 Secret。 +- `npm run verify`:运行提交前基线;需要真实 PostgreSQL gate 时加 `-- --postgres`。 +- `npm run deploy:local`:重建本地 Docker 服务并探测真实 HTTP 端点;加 `-- --memory` 会断言 memory/indexeddb overlay 生效。 +- `npm run deploy:space`:部署干净 git HEAD 到固定 Space,并做只读公网验证。 +- `npm run agent:doctor`:执行只读 Agent API 契约检查,不触发真实生图。 + 生成脚本常用参数: ```bash @@ -72,6 +95,19 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ "a product photo of a ceramic mug" ``` +启用 Agent 内部上游 SSE 时,必须显式传策略字段;脚本仍只输出最终 JSON: + +```bash +node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ + --allow-billable \ + --image-backend images-api \ + --streaming-strategy newapi-keepalive-sse \ + --partial-images 2 \ + --size 4096x4096 \ + --quality high \ + "a product photo of a ceramic mug" +``` + 真实生图必须显式开启: ```bash @@ -82,7 +118,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs \ "a product photo of a ceramic mug" ``` -生成脚本会在 capabilities 声明 `agent_jobs.supported=true` 后,对 `quality=high` 且最大边不小于 3072 的请求自动使用 job polling。也可以用 `--job` 强制 job polling,或用 `--no-job` 强制同步 Agent generate。 +生成脚本会对 `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`;默认不发送这些字段,保持服务端默认非流式基线。 编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。 @@ -101,14 +137,14 @@ OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream - `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。 - `GPT_IMAGE_AGENT_TOKEN`:Bearer token。 -- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时发送的 `X-App-Password-Hash`。 +- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时,Agent 端点发送为 `X-App-Password-Hash`,页面 SSE 发送为 form-data `passwordHash`。 - `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。 - `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。 - `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。 `GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。 -脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。 +脚本会把服务返回的相对产物路径补充为绝对 URL,页面 SSE 的相对 `path` 会补充 `absolute_path`,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。 ## 参考 diff --git a/skills/gpt-image-playground-agent/references/api.md b/skills/gpt-image-playground-agent/references/api.md index 87f1a29c57888d9aeb97bf91177868cf3e91dd51..4fb4b20a58b5cf06fd76ca211b7a20f8f6b802b2 100644 --- a/skills/gpt-image-playground-agent/references/api.md +++ b/skills/gpt-image-playground-agent/references/api.md @@ -1,5 +1,15 @@ # GPT Image Playground Agent API 参考 +## 目录 + +- [辅助脚本](#辅助脚本) +- [能力查询](#能力查询) +- [Job Polling](#job-polling) +- [生成图片](#生成图片) +- [编辑图片](#编辑图片) +- [产物元数据](#产物元数据) +- [错误](#错误) + ## 辅助脚本 - `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。 @@ -9,9 +19,10 @@ 生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。 上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。 脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。 -鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。 -当服务返回相对 `content_url` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` 和 `absolute_metadata_url`。 +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`。 +当服务返回相对 `content_url`、`metadata_url` 或页面 SSE `path` 时,辅助脚本会额外输出 `absolute_content_url`、`absolute_metadata_url` 或 `absolute_path`。 同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。 +页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。 生成脚本参数: @@ -61,24 +72,44 @@ GET /api/agent/capabilities 关键字段: -- `auth.required`:是否需要鉴权。 -- `auth.schemes`:当前部署实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。 +- `auth.required`:Agent 端点是否需要鉴权。 +- `auth.schemes`:Agent 端点当前实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。 - `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。 - `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。 - `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。 - `model_limits.gpt-image-2.max_aspect`:最大长短边比例,当前为 `3`。 - `model_limits.gpt-image-2.min_pixels`:最小总像素,当前为 `655360`。 - `model_limits.gpt-image-2.recommended_presets`:推荐尺寸预设。 -- `model_limits.gpt-image-2.high_4k_risk`:高质量 4K 级请求的长耗时风险说明。 +- `model_limits.gpt-image-2.large_image_risk`:大尺寸请求的长耗时风险说明,当前适用于 `max_edge>2048`。 - `agent_streaming.generate.mode`:当前为 `non_streaming_only`。 - `agent_streaming.edit.mode`:当前为 `non_streaming_only`。 +- `agent_streaming.upstream_sse`:Agent generate 内部消费上游 SSE 的能力,客户端响应仍是最终 `AgentImageResponse` JSON。 +- `agent_streaming.upstream_sse.image_backends`:支持 `images-api`、`responses-image-generation`。 +- `agent_streaming.upstream_sse.enabled_image_backends`:当前运行时可直接使用的 Agent 上游 SSE 后端;`responses-image-generation` 只有在所需环境变量齐备时才出现。 +- `agent_streaming.upstream_sse.streaming_strategies`:支持 `off`、`auto`、`openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。 +- `agent_streaming.upstream_sse.activation_strategies`:会真正向上游发送 `stream=true` 的策略,当前为 `openai-sse`、`newapi-keepalive-sse`、`responses-sse`、`force-sse`。 - `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。 +- `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。 +- `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。 +- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时必须使用页面端 `/api/images` SSE。 +- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。 +- `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。 +- `routing_rules.agent_generate_small_smoke`:普通小图单次文生图默认使用 `/api/agent/images/generate`。 +- `routing_rules.page_sse_large_generate`:`max_edge>2048` 的单次文生图推荐优先使用 `/api/images` SSE,失败后先诊断,再显式选择 `/api/agent/images/generate` 或 job 路径。 +- `routing_rules.retry_recovery`:终态失败不会用同一 `Idempotency-Key` 重新执行,必须诊断后创建新的业务操作和新的 key。 +- `defaults.image_backend`:Agent generate 默认 `images-api`。 +- `defaults.streaming_strategy`:Agent generate 默认 `off`,不会默认向上游发送 `stream=true`。 +- `defaults.partial_images`:Agent generate 默认 `2`,仅在显式启用上游 SSE 时使用。 +- `supported.image_backends`:机器可读的图片后端枚举。 +- `supported.enabled_image_backends`:当前运行时可直接使用的图片后端。 +- `supported.image_backend_requirements`:每个图片后端的 required env、missing env 和 enabled 状态;Responses 后端需要 `ENABLE_RESPONSES_IMAGE_BACKEND` 与 `OPENAI_RESPONSES_API_MODEL`。 +- `supported.streaming_strategies`:机器可读的流式兼容策略枚举。 - `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。 - `agent_jobs.mode`:当前为 `job_polling`。 - `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。 - `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。 -当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,4K/high 长耗时请求优先创建 job 并轮询结果;同步 Agent generate 仍适用于普通非流式请求。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。 +当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,job 路径仍然可用,但普通大图单次文生图的默认路径已经切到页面端 `/api/images` SSE。高分辨率 edit 和复杂 UI 批量生产不应走 Agent 非流式 edit,优先按 `routing_rules` 使用页面端 `/api/images` SSE。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。大图页面流式失败后不自动回退,先诊断再显式选新路径。 ## Job Polling @@ -147,11 +178,14 @@ Content-Type: application/json "output_format": "png", "background": "auto", "moderation": "auto", - "response_mode": "path" + "response_mode": "path", + "image_backend": "images-api", + "streaming_strategy": "off", + "partial_images": 2 } ``` -Agent 生成端点当前只支持非流式 JSON 响应。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。 +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`。 响应: @@ -198,6 +232,8 @@ Content-Type: multipart/form-data - `image_0..image_9`:源图片。 - `mask`:可选 PNG 遮罩。 +当 `size` 的最大边大于 `2048` 时,Agent edit 端点会返回 `validation_error`,不会联系上游;该场景必须按 `routing_rules.high_resolution_edit` 使用页面端 `/api/images` form-data SSE 路径。 + ## 产物元数据 ```http @@ -228,6 +264,8 @@ DELETE /api/agent/artifacts/{id} "selected_channel_id": "default", "upstream_host": "api.example.test", "upstream_status": 524, + "upstream_event_type": "image_generation.partial_image", + "partial_image_count": 1, "transport_error": false, "retry_after_seconds": 15, "channel_cooldown_scope": "channel", diff --git a/skills/gpt-image-playground-agent/scripts/edit-image.mjs b/skills/gpt-image-playground-agent/scripts/edit-image.mjs index 181146c0973a14ed9f8be81f87ab12fdd06b2a6e..03083737925e4c52791cd079a8012cfd86a11c59 100644 --- a/skills/gpt-image-playground-agent/scripts/edit-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/edit-image.mjs @@ -55,6 +55,7 @@ try { } if (options.dryRun || (!contractCheck && !options.allowBillable)) { + const routingGuidance = buildEditRoutingGuidance(options); console.log( JSON.stringify( { @@ -62,6 +63,7 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) { billable: false, dry_run: true, endpoint: `${baseUrl}/api/agent/images/edit`, + routing_guidance: routingGuidance, idempotency_key: idempotencyKey, request: { image_path: imagePath, @@ -80,6 +82,23 @@ if (options.dryRun || (!contractCheck && !options.allowBillable)) { process.exit(0); } +const routingGuidance = buildEditRoutingGuidance(options); +if (routingGuidance.strength === 'must_use') { + console.error( + JSON.stringify( + { + ok: false, + billable: false, + error: '当前请求命中高分辨率 edit 路由硬规则;请使用页面端 /api/images form-data SSE 路径。', + routing_guidance: routingGuidance + }, + null, + 2 + ) + ); + process.exit(2); +} + function parseArgs(argv) { const parsed = { model: 'gpt-image-2', @@ -124,6 +143,30 @@ function absoluteUrl(value) { return new URL(value, `${baseUrl}/`).toString(); } +function buildEditRoutingGuidance(parsed) { + if (readMaxImageEdge(parsed.size) > 2048) { + return { + recommended_endpoint: '/api/images', + transport: 'page_sse', + strength: 'must_use', + reason: 'Agent edit is non-streaming; high-resolution edit should use the page form-data SSE endpoint.' + }; + } + return { + recommended_endpoint: '/api/agent/images/edit', + transport: 'agent_json', + strength: 'default', + reason: 'Normal edit requests can use the Agent JSON response contract.' + }; +} + +function readMaxImageEdge(size) { + if (typeof size !== 'string') return 0; + const match = size.match(/^(\d+)x(\d+)$/); + if (!match) return 0; + return Math.max(Number(match[1]), Number(match[2])); +} + function enrichImageUrls(result) { if (!result || !Array.isArray(result.images)) return result; return { diff --git a/skills/gpt-image-playground-agent/scripts/generate-image.mjs b/skills/gpt-image-playground-agent/scripts/generate-image.mjs index 1a153d1cf04fc8620a8464a89b73da5a7b7bb2b5..4525b6592c81aa132aa78ff904c12ade1d375564 100644 --- a/skills/gpt-image-playground-agent/scripts/generate-image.mjs +++ b/skills/gpt-image-playground-agent/scripts/generate-image.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import crypto from 'node:crypto'; import fs from 'node:fs'; +import { AGENT_ENDPOINTS, buildAgentJobResultPath } from '../../../src/lib/agent-api-paths.mjs'; import { errorMessage, normalizeBaseUrl, @@ -12,6 +13,20 @@ import { sleep } from './lib/script-utils.mjs'; +const IMAGE_BACKENDS = new Set(['images-api', 'images', 'responses', 'responses-image-generation']); +const RESPONSE_MODES = new Set(['path', 'base64', 'both']); +const STREAMING_STRATEGIES = new Set([ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' +]); +const MIN_PARTIAL_IMAGES = 1; +const MAX_PARTIAL_IMAGES = 3; +const MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH = 128; +const PAGE_SSE_ENDPOINT = '/api/images'; const token = process.env.GPT_IMAGE_AGENT_TOKEN || ''; const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || ''; const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check'); @@ -73,8 +88,9 @@ if (isNonBillableDryRun(options, contractCheck)) { ok: true, billable: false, dry_run: true, - endpoint: dryRunEndpoint(options.jobMode), - job_mode: options.jobMode, + endpoint: dryRunEndpoint(requestBody, options.routeMode), + route_mode: options.routeMode, + routing_guidance: buildGenerateRoutingGuidance(requestBody, options.routeMode), idempotency_key: idempotencyKey, request: requestBody, next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。' @@ -89,6 +105,10 @@ if (isNonBillableDryRun(options, contractCheck)) { try { var capabilities = await readCapabilities(); } catch (error) { + if (isScriptError(error)) { + console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + process.exit(1); + } console.error(errorMessage(error)); process.exit(1); } @@ -99,12 +119,31 @@ if (contractCheck) { } try { - if (shouldUseJobPolling(capabilities, requestBody, options.jobMode)) { + if (shouldUseJobPolling(capabilities, options.routeMode)) { await runGenerateJob(); + } else if (shouldUsePageSse(capabilities, requestBody, options.routeMode)) { + try { + const result = await runPageSseRequest(); + console.log( + JSON.stringify( + buildSuccessOutput(formatPageSseOutput(result), { transport: 'page_sse', endpoint: PAGE_SSE_ENDPOINT }), + null, + 2 + ) + ); + process.exit(0); + } catch (error) { + console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + process.exit(1); + } } else { - await runGenerateRequest(); + await runGenerateRequest({ routing: { transport: 'agent_json', endpoint: AGENT_ENDPOINTS.generate } }); } } catch (error) { + if (isScriptError(error)) { + console.error(JSON.stringify(buildPageSseFailureOutput(error), null, 2)); + process.exit(1); + } console.error(errorMessage(error)); process.exit(1); } @@ -117,10 +156,13 @@ function parseArgs(argv) { n: '1', format: 'png', responseMode: 'path', + imageBackend: undefined, + streamingStrategy: undefined, + partialImages: undefined, timeoutMs: undefined, promptFile: undefined, idempotencyKey: undefined, - jobMode: 'auto', + routeMode: 'auto', dryRun: false, allowBillable: false, help: false, @@ -130,8 +172,9 @@ function parseArgs(argv) { const arg = argv[index]; if (arg === '--dry-run') parsed.dryRun = true; else if (arg === '--allow-billable') parsed.allowBillable = true; - else if (arg === '--job') parsed.jobMode = 'always'; - else if (arg === '--no-job') parsed.jobMode = 'never'; + else if (arg === '--job') parsed.routeMode = 'job'; + else if (arg === '--no-job' || arg === '--agent') parsed.routeMode = 'agent'; + else if (arg === '--page-sse') parsed.routeMode = 'page_sse'; else if (arg === '--help' || arg === '-h') parsed.help = true; else if (arg === '--contract-check') continue; else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg); @@ -140,6 +183,9 @@ function parseArgs(argv) { else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg); else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg); else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg); + else if (arg === '--image-backend') parsed.imageBackend = readOptionValue(argv, (index += 1), arg); + else if (arg === '--streaming-strategy') parsed.streamingStrategy = readOptionValue(argv, (index += 1), arg); + else if (arg === '--partial-images') parsed.partialImages = readOptionValue(argv, (index += 1), arg); else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg); else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg); else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg); @@ -160,32 +206,71 @@ function readPrompt(parsed, { readPromptFile }) { } function buildRequestBody(promptValue, parsed) { - return { - prompt: promptValue || 'contract check', - model: parsed.model, - n: readConfiguredPositiveInteger(parsed.n, '--n', 1), - size: parsed.size, - quality: parsed.quality, - output_format: normalizeOutputFormat(parsed.format), - response_mode: parsed.responseMode - }; + return addUpstreamStrategyFields( + { + prompt: promptValue || 'contract check', + model: parsed.model, + n: readConfiguredPositiveInteger(parsed.n, '--n', 1), + size: parsed.size, + quality: parsed.quality, + output_format: normalizeOutputFormat(parsed.format), + response_mode: parsed.responseMode + }, + parsed + ); } function buildDryRunRequestBody(parsed) { - const body = { - model: parsed.model, - n: readConfiguredPositiveInteger(parsed.n, '--n', 1), - size: parsed.size, - quality: parsed.quality, - output_format: normalizeOutputFormat(parsed.format), - response_mode: parsed.responseMode - }; + const body = addUpstreamStrategyFields( + { + model: parsed.model, + n: readConfiguredPositiveInteger(parsed.n, '--n', 1), + size: parsed.size, + quality: parsed.quality, + output_format: normalizeOutputFormat(parsed.format), + response_mode: parsed.responseMode + }, + parsed + ); if (parsed.promptFile) { return { ...body, prompt_file: parsed.promptFile }; } return { ...body, prompt: parsed.promptParts.join(' ') }; } +function addUpstreamStrategyFields(body, parsed) { + validateUpstreamStrategyOptions(parsed); + return { + ...body, + ...(parsed.imageBackend ? { image_backend: parsed.imageBackend } : {}), + ...(parsed.streamingStrategy ? { streaming_strategy: parsed.streamingStrategy } : {}), + ...(parsed.partialImages ? { partial_images: readPartialImages(parsed.partialImages) } : {}) + }; +} + +function validateUpstreamStrategyOptions(parsed) { + if (!RESPONSE_MODES.has(parsed.responseMode)) { + throw new Error('--response-mode 必须是 path、base64 或 both。'); + } + if (parsed.imageBackend && !IMAGE_BACKENDS.has(parsed.imageBackend)) { + throw new Error('--image-backend 必须是 images-api、images、responses 或 responses-image-generation。'); + } + if (parsed.streamingStrategy && !STREAMING_STRATEGIES.has(parsed.streamingStrategy)) { + throw new Error('--streaming-strategy 必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'); + } + if (parsed.routeMode === 'page_sse' && parsed.streamingStrategy === 'off') { + throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。'); + } +} + +function readPartialImages(value) { + const parsed = readConfiguredPositiveInteger(value, '--partial-images', 2); + if (parsed < MIN_PARTIAL_IMAGES || parsed > MAX_PARTIAL_IMAGES) { + throw new Error('--partial-images 必须是 1 到 3 的整数。'); + } + return parsed; +} + function hasPromptSource(parsed) { return Boolean(parsed.promptFile || parsed.promptParts.length > 0); } @@ -217,14 +302,47 @@ function enrichImageUrls(result) { }; } -function dryRunEndpoint(jobMode) { - if (jobMode === 'always') return `${baseUrl}/api/agent/jobs/images/generate`; - if (jobMode === 'never') return `${baseUrl}/api/agent/images/generate`; - return `${baseUrl}/api/agent/images/generate 或 ${baseUrl}/api/agent/jobs/images/generate`; +function dryRunEndpoint(body, routeMode) { + if (routeMode === 'job') return `${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`; + if (routeMode === 'agent') return `${baseUrl}${AGENT_ENDPOINTS.generate}`; + if (routeMode === 'page_sse') return `${baseUrl}${PAGE_SSE_ENDPOINT}`; + return isLargeGenerate(body) && isPageSseAllowed(body) + ? `${baseUrl}${PAGE_SSE_ENDPOINT}` + : `${baseUrl}${AGENT_ENDPOINTS.generate}`; +} + +function buildGenerateRoutingGuidance(body, routeMode) { + if (routeMode === 'job') { + return { + recommended_endpoint: AGENT_ENDPOINTS.create_generate_job, + transport: 'agent_job_polling', + strength: 'recommended', + reason: 'Explicit --job requests use Agent job polling.' + }; + } + if (routeMode === 'page_sse' && !isPageSseAllowed(body)) { + throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。'); + } + if ((routeMode === 'page_sse' || (routeMode !== 'agent' && isLargeGenerate(body))) && isPageSseAllowed(body)) { + return { + recommended_endpoint: PAGE_SSE_ENDPOINT, + transport: 'page_sse', + strength: 'recommended', + fallback_endpoint: AGENT_ENDPOINTS.generate, + fallback_mode: 'manual_after_diagnosis', + 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.' + }; + } + return { + recommended_endpoint: AGENT_ENDPOINTS.generate, + transport: 'agent_json', + strength: 'default', + reason: 'Normal single-image generate requests use the Agent JSON response contract.' + }; } async function readCapabilities() { - const { response, result, text } = await fetchJson(`${baseUrl}/api/agent/capabilities`, { + const { response, result, text } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.capabilities}`, { headers: authHeaders(), timeoutMs }); @@ -234,12 +352,12 @@ async function readCapabilities() { return result; } -async function runGenerateRequest() { +async function runGenerateRequest(options = {}) { let lastResult; let lastRetryAfter = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, { + const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -251,7 +369,7 @@ async function runGenerateRequest() { }); if (response.ok) { - console.log(JSON.stringify(enrichImageUrls(result), null, 2)); + console.log(JSON.stringify(buildSuccessOutput(enrichImageUrls(result), options.routing), null, 2)); process.exit(0); } @@ -266,12 +384,340 @@ async function runGenerateRequest() { process.exit(1); } +async function runPageSseRequest() { + const url = `${baseUrl}${PAGE_SSE_ENDPOINT}`; + const formData = buildPageSseFormData(); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + let response; + try { + response = await fetch(url, { + method: 'POST', + body: formData, + signal: controller.signal + }); + } catch (error) { + throw new Error(`请求失败:${url}。${errorMessage(error)}`); + } + try { + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) { + return await collectPageSseResult(response, controller.signal); + } + const text = await response.text(); + if (!response.ok) { + throw createPageSseHttpError(response.status, readErrorFromJsonText(text) || text); + } + return parseJsonResponse(text, true, url); + } catch (error) { + if (controller.signal.aborted) { + throw new Error(`请求失败:${url}。${errorMessage(error)}`); + } + throw error; + } + } finally { + clearTimeout(timeout); + } +} + +function assertPageSseReady(capabilitiesValue) { + const pageSse = capabilitiesValue?.agent_streaming?.page_sse; + if (!supportsPageSse(capabilitiesValue)) { + throw createScriptError( + 'page_sse_unavailable', + '大图默认路由需要 agent_streaming.page_sse.supported=true;capabilities 未声明时不能静默降级到 Agent JSON。' + ); + } + if (pageSse?.auth?.required === true && !passwordHash) { + throw createScriptError( + 'page_sse_auth_required', + '页面 SSE 路径需要表单字段 passwordHash;请设置 GPT_IMAGE_APP_PASSWORD_HASH 后重试。' + ); + } +} + +function buildPageSseFormData() { + const formData = new FormData(); + assertPageSseClientRequestIdLength(idempotencyKey); + formData.append('mode', 'generate'); + formData.append('prompt', requestBody.prompt); + formData.append('model', requestBody.model); + formData.append('n', String(requestBody.n)); + formData.append('size', requestBody.size); + formData.append('quality', requestBody.quality); + formData.append('output_format', requestBody.output_format); + formData.append('response_mode', requestBody.response_mode); + formData.append('clientRequestId', idempotencyKey); + formData.append('stream', 'true'); + formData.append('partial_images', String(requestBody.partial_images || 2)); + if (requestBody.image_backend) formData.append('image_backend', normalizeImageBackendForPage(requestBody.image_backend)); + if (requestBody.streaming_strategy) { + formData.append('image_streaming_strategy', requestBody.streaming_strategy); + } + if (requestBody.background) formData.append('background', requestBody.background); + if (requestBody.moderation) formData.append('moderation', requestBody.moderation); + if (requestBody.output_compression !== undefined) { + formData.append('output_compression', String(requestBody.output_compression)); + } + if (passwordHash) formData.append('passwordHash', passwordHash); + return formData; +} + +function assertPageSseClientRequestIdLength(clientRequestId) { + if (clientRequestId.length > MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH) { + throw createScriptError( + 'page_sse_client_request_id_too_long', + `页面 SSE 的 clientRequestId 不能超过 ${MAX_PAGE_SSE_CLIENT_REQUEST_ID_LENGTH} 个字符;请缩短 Idempotency-Key。` + ); + } +} + +function formatPageSseOutput(result) { + if (!result || !Array.isArray(result.images)) return result; + return { + ...result, + images: result.images.map((image) => formatPageSseImage(image)) + }; +} + +function formatPageSseImage(image) { + const output = { ...image }; + if (output.path) { + output.absolute_path = absoluteUrl(output.path); + if (requestBody.response_mode === 'path') { + delete output.b64_json; + } + } + return output; +} + +function normalizeImageBackendForPage(value) { + if (value === 'images') return 'images-api'; + if (value === 'responses') return 'responses-image-generation'; + return value; +} + +function readPageSseClientRequestId(event) { + if (typeof event.clientRequestId === 'string') return event.clientRequestId; + if (typeof event.client_request_id === 'string') return event.client_request_id; + return undefined; +} + +function normalizePageSseImage(image, fallbackClientRequestId) { + const clientRequestId = image.clientRequestId || image.client_request_id || fallbackClientRequestId; + return { + ...image, + output_format: image.outputFormat || image.output_format || requestBody.output_format, + ...(clientRequestId ? { clientRequestId } : {}) + }; +} + +function mergePageSseDoneImages(doneImages, completedImages, fallbackClientRequestId) { + if (!Array.isArray(doneImages) || doneImages.length === 0) { + return completedImages.map((image) => normalizePageSseImage(image, fallbackClientRequestId)); + } + const imageCount = Math.max(doneImages.length, completedImages.length); + return Array.from({ length: imageCount }, (_, index) => + normalizePageSseImage({ ...(completedImages[index] || {}), ...(doneImages[index] || {}) }, fallbackClientRequestId) + ); +} + +async function collectPageSseResult(response, signal) { + const reader = response.body?.getReader(); + if (!reader) throw new Error('页面 SSE 响应缺少 body。'); + const decoder = new TextDecoder(); + const state = { completedImages: [], usage: undefined, actualCost: undefined, doneReceived: false }; + let buffer = ''; + while (true) { + const { done, value } = await readPageSseChunk(reader, signal); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const events = buffer.split(/\r?\n\r?\n/); + buffer = events.pop() || ''; + for (const rawEvent of events) { + applyPageSseEvent(state, rawEvent); + } + } + buffer += decoder.decode(); + if (buffer.trim()) applyPageSseEvent(state, buffer); + if (state.completedImages.length === 0) { + throw new Error('页面 SSE 未返回最终图片。'); + } + if (!state.doneReceived) { + throw new Error('页面 SSE 缺少最终 done 事件,流式响应可能已提前中断。'); + } + return { images: state.completedImages, usage: state.usage, actualCost: state.actualCost }; +} + +function readPageSseChunk(reader, signal) { + if (!signal) return reader.read(); + if (signal.aborted) { + return Promise.reject(new Error('请求超时。')); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('请求超时。')); + signal.addEventListener('abort', onAbort, { once: true }); + reader.read().then(resolve, reject).finally(() => { + signal.removeEventListener('abort', onAbort); + }); + }); +} + +function applyPageSseEvent(state, rawEvent) { + const event = parsePageSseEvent(rawEvent); + if (!event) return; + if (event.type === 'error') { + throw createPageSseStreamError(event); + } + if (event.type === 'completed' && event.filename) { + state.completedImages.push( + normalizePageSseImage( + { + filename: event.filename, + b64_json: event.b64_json, + path: event.path, + output_format: event.outputFormat || event.output_format || requestBody.output_format + }, + readPageSseClientRequestId(event) + ) + ); + return; + } + if (event.type === 'done') { + state.doneReceived = true; + const clientRequestId = readPageSseClientRequestId(event); + state.completedImages = mergePageSseDoneImages(event.images, state.completedImages, clientRequestId); + state.usage = event.usage; + state.actualCost = event.actualCost !== undefined ? event.actualCost : event.actual_cost; + } +} + +function formatPageSseError(value) { + if (typeof value === 'string' && value.trim()) return value; + if (value && typeof value === 'object') { + if (typeof value.message === 'string' && value.message.trim()) return value.message; + if (typeof value.code === 'string' && value.code.trim()) return value.code; + try { + return JSON.stringify(value); + } catch { + return '页面 SSE 返回错误事件。'; + } + } + return '页面 SSE 返回错误事件。'; +} + +function createPageSseStreamError(event) { + const error = new Error(formatPageSseError(event.error)); + const status = readPageSseStreamStatus(event); + if (Number.isInteger(status)) { + error.streamStatus = status; + } + return error; +} + +function readPageSseStreamStatus(event) { + if (Number.isInteger(event.status)) return event.status; + if (event.error && typeof event.error === 'object' && Number.isInteger(event.error.status)) { + return event.error.status; + } + return undefined; +} + +function parsePageSseEvent(rawEvent) { + const lines = rawEvent.split(/\r?\n/); + const data = lines + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join('\n') + .trim(); + if (!data || data === '[DONE]') return undefined; + try { + return JSON.parse(data); + } catch (error) { + throw new Error(`页面 SSE 事件不是有效 JSON:${errorMessage(error)}`); + } +} + +function buildSuccessOutput(result, routing) { + return routing ? { ...result, routing } : result; +} + +function buildPageSseFailureOutput(error) { + if (isScriptError(error)) { + return buildPageSseScriptFailure(error); + } + if (isPageSseRequestRejected(error)) { + return buildPageSseRequestRejectedFailure(error); + } + return buildBillablePageSseFailure(error); +} + +function buildPageSseRouting(fallbackMode) { + return { + transport: 'page_sse', + endpoint: PAGE_SSE_ENDPOINT, + fallback_endpoint: AGENT_ENDPOINTS.generate, + fallback_mode: fallbackMode + }; +} + +function buildPageSseScriptFailure(error) { + return { + ok: false, + billable: false, + error: { + code: error.scriptCode, + message: errorMessage(error) + }, + routing: buildPageSseRouting('manual_after_diagnosis'), + next_step: '先补齐页面流式 capability 或访问码哈希,再重新执行;不要静默切换到 Agent JSON。' + }; +} + +function buildPageSseRequestRejectedFailure(error) { + return { + ok: false, + billable: false, + error: { + code: 'page_sse_request_rejected', + status: error.status, + message: errorMessage(error) + }, + routing: buildPageSseRouting('fix_request_before_retry'), + next_step: '先修正页面端拒绝的请求参数或鉴权,再重新执行;这类本地 4xx 不应按上游计费失败处理。' + }; +} + +function buildBillablePageSseFailure(error) { + return { + ok: false, + billable: true, + error: { + code: 'page_sse_failed', + ...buildPageSseFailureStatus(error), + message: errorMessage(error) + }, + routing: buildPageSseRouting('manual_after_diagnosis'), + next_step: + '先诊断页面流式失败原因,再决定是否用 --agent 重新执行同一业务请求;不要自动重试同一个请求。' + }; +} + +function buildPageSseFailureStatus(error) { + if (error && typeof error === 'object') { + if (Number.isInteger(error.streamStatus)) return { status: error.streamStatus }; + if (Number.isInteger(error.status)) return { status: error.status }; + } + return {}; +} + async function runGenerateJob() { let lastResult; let lastRetryAfter = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const { response, result } = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, { + const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -303,7 +749,7 @@ async function pollJobResult(job) { if (!job || typeof job.id !== 'string') { throw new Error('创建 job 的响应缺少 job.id。'); } - const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || `/api/agent/jobs/${job.id}/result`, 'job.result_url'); + const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || buildAgentJobResultPath(job.id), 'job.result_url'); const deadlineMs = Date.now() + timeoutMs; let lastResult; let lastRetryAfter = job.retry_after_seconds || 1; @@ -328,7 +774,7 @@ async function pollJobResult(job) { async function runContractCheck(capabilitiesValue) { const checks = []; - const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, { + const { response, result } = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.generate}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -338,14 +784,14 @@ async function runContractCheck(capabilitiesValue) { timeoutMs }); if (response.status === 400 && result?.error?.code === 'idempotency_key_required') { - checks.push({ endpoint: '/api/agent/images/generate', status: response.status, error_code: result.error.code }); + checks.push({ endpoint: AGENT_ENDPOINTS.generate, status: response.status, error_code: result.error.code }); } else { console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2)); process.exit(1); } if (supportsJobPolling(capabilitiesValue)) { - const jobCheck = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, { + const jobCheck = await fetchJson(`${baseUrl}${AGENT_ENDPOINTS.create_generate_job}`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -361,7 +807,7 @@ async function runContractCheck(capabilitiesValue) { process.exit(1); } checks.push({ - endpoint: '/api/agent/jobs/images/generate', + endpoint: AGENT_ENDPOINTS.create_generate_job, status: jobCheck.response.status, error_code: jobCheck.result.error.code }); @@ -371,15 +817,27 @@ async function runContractCheck(capabilitiesValue) { } async function fetchJson(url, init) { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), init.timeoutMs); try { - const fetchInit = { ...init }; - delete fetchInit.timeoutMs; - const response = await fetch(url, { ...fetchInit, signal: controller.signal }); + const response = await fetchWithTimeout(url, init); const text = await response.text(); const result = parseJsonResponse(text, response.ok, url); return { response, result, text }; + } catch (error) { + const message = errorMessage(error); + if (message.startsWith(`请求失败:${url}。`)) { + throw error; + } + throw new Error(`请求失败:${url}。${message}`); + } +} + +async function fetchWithTimeout(url, init) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), init.timeoutMs ?? timeoutMs); + try { + const fetchInit = { ...init }; + delete fetchInit.timeoutMs; + return await fetch(url, { ...fetchInit, signal: controller.signal }); } catch (error) { const message = errorMessage(error); throw new Error(`请求失败:${url}。${message}`); @@ -388,6 +846,18 @@ async function fetchJson(url, init) { } } +function readErrorFromJsonText(text) { + let result; + try { + result = parseJsonResponse(text, false, ''); + } catch { + return undefined; + } + if (typeof result?.error === 'string') return result.error; + if (typeof result?.error?.message === 'string') return result.error.message; + return undefined; +} + function parseJsonResponse(text, isOk, url) { if (!text) return null; try { @@ -407,16 +877,39 @@ function supportsJobPolling(capabilitiesValue) { return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling'); } -function shouldUseJobPolling(capabilitiesValue, request, jobMode) { - if (jobMode === 'never') return false; +function supportsPageSse(capabilitiesValue) { + return Boolean(capabilitiesValue?.agent_streaming?.page_sse?.supported === true); +} + +function shouldUseJobPolling(capabilitiesValue, routeMode) { + if (routeMode !== 'job') return false; if (!supportsJobPolling(capabilitiesValue)) { - if (jobMode === 'always') { - throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。'); + throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。'); + } + return true; +} + +function shouldUsePageSse(capabilitiesValue, request, routeMode) { + if (routeMode === 'agent' || routeMode === 'job') return false; + if (!isPageSseAllowed(request)) { + if (routeMode === 'page_sse') { + throw new Error('streaming_strategy=off 时不能强制使用页面 SSE。'); } return false; } - if (jobMode === 'always') return true; - return request.quality === 'high' && readMaxImageEdge(request.size) >= 3072; + if (routeMode === 'page_sse' || isLargeGenerate(request)) { + assertPageSseReady(capabilitiesValue); + return true; + } + return false; +} + +function isLargeGenerate(request) { + return readMaxImageEdge(request.size) > 2048; +} + +function isPageSseAllowed(request) { + return request.streaming_strategy !== 'off'; } function readMaxImageEdge(size) { @@ -426,9 +919,38 @@ function readMaxImageEdge(size) { return Math.max(Number(match[1]), Number(match[2])); } +function createScriptError(code, message) { + const error = new Error(message); + error.scriptCode = code; + return error; +} + +function isScriptError(error) { + return Boolean(error && typeof error === 'object' && typeof error.scriptCode === 'string'); +} + +function createPageSseHttpError(status, detail) { + const message = detail ? `页面 SSE 请求失败,状态码 ${status}:${detail}` : `页面 SSE 请求失败,状态码 ${status}。`; + const error = new Error(message); + error.status = status; + return error; +} + +function isPageSseRequestRejected(error) { + return Boolean( + error && + typeof error === 'object' && + Number.isInteger(error.status) && + error.status >= 400 && + error.status < 500 + ); +} + function printUsage() { console.error('用法:generate-image.mjs [options] '); console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。'); - console.error('常用参数:--model --size --quality --n --format --response-mode --timeout-ms --prompt-file --idempotency-key --job --no-job'); + console.error( + '常用参数:--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(兼容别名)' + ); console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check'); } diff --git a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs index 9f6eda7b8f72cfa59a61c5d0775c7d5def1ab974..3a7f89eabbed5cbb2aa9fd0f82d0a790871cdbe8 100644 --- a/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +++ b/skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs @@ -1,3 +1,8 @@ +import { + CHINESE_POSITIVE_INTEGER_MESSAGES, + parsePositiveIntegerConfig +} from '../../../../src/lib/positive-integer-config.mjs'; + const MAX_RETRY_AFTER_SECONDS = 60; export function readOptionValue(argv, index, name) { @@ -9,15 +14,9 @@ export function readOptionValue(argv, index, name) { } export function readConfiguredPositiveInteger(value, name, fallback) { - if (value === undefined || value === null || value === '') return fallback; - if (!/^\d+$/.test(String(value))) { - throw new Error(`${name} 必须是正整数。`); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error(`${name} 必须是正整数。`); - } - return parsed; + return parsePositiveIntegerConfig(value, name, fallback, { + messages: CHINESE_POSITIVE_INTEGER_MESSAGES + }); } export function normalizeBaseUrl(value) { diff --git a/src/app/api/agent/agent-routes.test.ts b/src/app/api/agent/agent-routes.test.ts index 024c85ee8f8cf1d1058e882f276c3a9e325ca37c..cc89ba92b27992f3f077cccd280286fba283cd59 100644 --- a/src/app/api/agent/agent-routes.test.ts +++ b/src/app/api/agent/agent-routes.test.ts @@ -1,14 +1,16 @@ +import type { AgentErrorCode } from '@/lib/api-error-response'; +import Database from 'better-sqlite3'; +import type { NextRequest } from 'next/server'; import assert from 'node:assert/strict'; import { mkdtemp, readdir, rm } from 'node:fs/promises'; import http from 'node:http'; +import type { Socket } from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { afterEach, beforeEach, describe, it } from 'node:test'; -import Database from 'better-sqlite3'; import { Pool } from 'pg'; -const PNG_BASE64 = - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; let originalEnv: NodeJS.ProcessEnv; let originalCwd = ''; @@ -41,7 +43,9 @@ beforeEach(async () => { }); afterEach(async () => { - const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); + const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import( + '@/lib/agent-state-runtime' + ); const { resetServerChannelStateForTests } = await import('@/lib/server-channel-router'); restoreProcessEnv(originalEnv); process.chdir(originalCwd); @@ -80,6 +84,23 @@ describe('Agent route integration', () => { assert.deepEqual(body.auth.schemes, []); }); + it('reports enabled Responses image backend from the deployed runtime environment', async () => { + const { getCapabilities } = await loadAgentRoutes(); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + + const response = await getCapabilities(); + assert.equal(response.status, 200); + const body = await response.json(); + assert.deepEqual(body.supported.enabled_image_backends, ['images-api', 'responses-image-generation']); + assert.equal(body.supported.image_backend_requirements['responses-image-generation'].enabled, true); + assert.deepEqual(body.supported.image_backend_requirements['responses-image-generation'].missing_env, []); + assert.deepEqual(body.agent_streaming.upstream_sse.enabled_image_backends, [ + 'images-api', + 'responses-image-generation' + ]); + }); + it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => { const { generateImage } = await loadAgentRoutes(); let upstreamCalls = 0; @@ -113,6 +134,371 @@ describe('Agent route integration', () => { await upstream.close(); }); + it('does not send upstream stream parameters when Agent streaming_strategy is off', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startImageUpstream((body) => { + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-upstream-stream-off-key', { + prompt: 'agent upstream stream off', + streaming_strategy: 'off' + }) + ); + + assert.equal(response.status, 200); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, false); + assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false); + } finally { + await upstream.close(); + } + }); + + it('consumes upstream image SSE internally while keeping the Agent generate response non-streaming', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'image_generation.partial_image', + data: { type: 'image_generation.partial_image', b64_json: 'agent-partial-base64' } + }, + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-upstream-sse-key', { + prompt: 'agent upstream sse', + response_mode: 'base64', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 2 + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.cached, false); + assert.equal(body.images[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody); + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('consumes JSON Images responses returned to Agent stream requests as final results', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startImageUpstream((body) => { + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-stream-json-fallback-key', { + prompt: 'agent stream json fallback', + response_mode: 'base64', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 2 + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.images[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('uses force-sse for Agent upstream image SSE while keeping the final JSON contract', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-force-sse-key', { + prompt: 'agent force sse', + response_mode: 'base64', + streaming_strategy: 'force-sse', + partial_images: 3 + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.images[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 3); + } finally { + await upstream.close(); + } + }); + + it('consumes Responses image_generation SSE internally while keeping the Agent generate response non-streaming', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingResponsesImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'response.image_generation_call.partial_image', + data: { + type: 'response.image_generation_call.partial_image', + partial_image_b64: 'agent-responses-partial-base64', + partial_image_index: 0 + } + }, + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + } + ]; + }); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-responses-upstream-sse-key', { + prompt: 'agent responses upstream sse', + response_mode: 'base64', + image_backend: 'responses-image-generation', + streaming_strategy: 'responses-sse', + partial_images: 2 + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.cached, false); + assert.equal(body.images[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-5.4'); + assert.equal(upstreamJson.stream, true); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].type, 'image_generation'); + assert.equal(tools[0].partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('uses force-sse for Agent Responses image_generation SSE while keeping the final JSON contract', async () => { + const { generateImage } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingResponsesImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + } + ]; + }); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-responses-force-sse-key', { + prompt: 'agent responses force sse', + response_mode: 'base64', + image_backend: 'responses-image-generation', + streaming_strategy: 'force-sse', + partial_images: 3 + }) + ); + + assert.equal(response.status, 200); + assert.notEqual(response.headers.get('content-type'), 'text/event-stream'); + const body = await response.json(); + assert.equal(body.images[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-5.4'); + assert.equal(upstreamJson.stream, true); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].type, 'image_generation'); + assert.equal(tools[0].partial_images, 3); + } finally { + await upstream.close(); + } + }); + + it('fails Agent Responses upstream SSE requests when partial images arrive without a final image', async () => { + const { generateImage } = await loadAgentRoutes(); + const upstream = await startStreamingResponsesImageUpstream(() => [ + { + event: 'response.image_generation_call.partial_image', + data: { + type: 'response.image_generation_call.partial_image', + partial_image_b64: 'agent-responses-partial-only', + partial_image_index: 0 + } + } + ]); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-responses-upstream-sse-partial-only-key', { + prompt: 'agent responses upstream sse partial only', + image_backend: 'responses-image-generation', + streaming_strategy: 'responses-sse', + partial_images: 2 + }) + ); + + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error.code, 'upstream_unavailable'); + assert.match(body.error.message, /最终图片 b64_json/); + assert.equal(body.error.diagnostics.upstream_event_type, 'response.image_generation_call.partial_image'); + assert.equal(body.error.diagnostics.partial_image_count, 1); + assert.equal(body.error.diagnostics.retry_after_seconds, 15); + assert.equal(JSON.stringify(body).includes('agent-responses-partial-only'), false); + } finally { + await upstream.close(); + } + }); + + it('fails Agent Responses upstream SSE requests when the image_generation_call fails', async () => { + const { generateImage } = await loadAgentRoutes(); + const upstream = await startStreamingResponsesImageUpstream(() => [ + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'failed', + error: { + code: 'content_policy_violation', + message: 'blocked by upstream policy' + } + } + } + } + ]); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-responses-upstream-sse-failed-call-key', { + prompt: 'agent responses upstream sse failed call', + image_backend: 'responses-image-generation', + streaming_strategy: 'responses-sse', + partial_images: 2 + }) + ); + + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error.code, 'upstream_unavailable'); + assert.match(body.error.message, /blocked by upstream policy/); + } finally { + await upstream.close(); + } + }); + + it('fails Agent upstream SSE requests when partial images arrive without a final image', async () => { + const { generateImage } = await loadAgentRoutes(); + const upstream = await startStreamingImageUpstream(() => [ + { + event: 'image_generation.partial_image', + data: { type: 'image_generation.partial_image', b64_json: 'agent-partial-only' } + } + ]); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await generateImage( + agentJsonRequest('agent-upstream-sse-partial-only-key', { + prompt: 'agent upstream sse partial only', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 2 + }) + ); + + assert.equal(response.status, 502); + const body = await response.json(); + assert.equal(body.error.code, 'upstream_unavailable'); + assert.match(body.error.message, /最终图片 b64_json/); + assert.equal(body.error.diagnostics.upstream_event_type, 'image_generation.partial_image'); + assert.equal(body.error.diagnostics.partial_image_count, 1); + } finally { + await upstream.close(); + } + }); + it('generates and replays through the memory state backend without creating SQLite state', async () => { process.env.AGENT_STATE_BACKEND = 'memory'; delete process.env.AGENT_SQLITE_PATH; @@ -126,12 +512,16 @@ describe('Agent route integration', () => { process.env.OPENAI_API_BASE_URL = upstream.baseUrl; try { - const first = await generateImage(agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' })); + const first = await generateImage( + agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' }) + ); assert.equal(first.status, 200); const firstBody = await first.json(); assert.equal(firstBody.cached, false); - const second = await generateImage(agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' })); + const second = await generateImage( + agentJsonRequest('route-memory-cache-key', { prompt: 'agent memory route success' }) + ); assert.equal(second.status, 200); const secondBody = await second.json(); assert.equal(secondBody.cached, true); @@ -251,6 +641,7 @@ describe('Agent route integration', () => { const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' })); assert.equal(replay.status, 502); assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); + assert.equal(replay.headers.get('x-request-id'), body.error.request_id); const replayBody = await replay.json(); assert.equal(replayBody.error.code, 'upstream_unavailable'); assert.equal(replayBody.error.retryable, false); @@ -276,7 +667,9 @@ describe('Agent route integration', () => { process.env.OPENAI_API_BASE_URL = upstream.baseUrl; try { - const created = await createGenerateJob(agentJobJsonRequest('route-job-key', { prompt: 'agent route job' })); + const created = await createGenerateJob( + agentJobJsonRequest('route-job-key', { prompt: 'agent route job' }) + ); assert.equal(created.status, 202); const createdBody = await created.json(); assert.equal(createdBody.job.state, 'running'); @@ -304,6 +697,259 @@ describe('Agent route integration', () => { } }); + it('creates a generate job that consumes upstream image SSE and saves the final artifact', async () => { + const { createGenerateJob, getJobResult, getArtifactContent } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'image_generation.partial_image', + data: { type: 'image_generation.partial_image', b64_json: 'job-partial-base64' } + }, + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob( + agentJobJsonRequest('route-job-upstream-sse-key', { + prompt: 'agent job upstream sse', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 2 + }) + ); + assert.equal(created.status, 202); + const createdBody = await created.json(); + + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 200); + assert.notEqual(result.headers.get('content-type'), 'text/event-stream'); + const resultBody = await result.json(); + assert.equal(resultBody.request_id, createdBody.job.id); + assert.equal(resultBody.cached, false); + assert.equal(resultBody.images.length, 1); + assert.equal('b64_json' in resultBody.images[0], false); + assert.match(resultBody.images[0].content_url, /^\/api\/agent\/artifacts\/[^/]+\/content$/); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + + const artifactId = resultBody.images[0].content_url.split('/').at(-2); + assert.equal(typeof artifactId, 'string'); + const content = await getArtifactContent( + new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), + { params: Promise.resolve({ id: artifactId }) } + ); + assert.equal(content.status, 200); + assert.equal( + Buffer.compare(Buffer.from(await content.arrayBuffer()), Buffer.from(PNG_BASE64, 'base64')), + 0 + ); + } finally { + await upstream.close(); + } + }); + + it('fails image upstream SSE generate jobs when partial images arrive without a final image', async () => { + const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); + const upstream = await startStreamingImageUpstream(() => [ + { + event: 'image_generation.partial_image', + data: { type: 'image_generation.partial_image', b64_json: 'job-images-partial-only' } + } + ]); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob( + agentJobJsonRequest('route-job-images-missing-final-key', { + prompt: 'agent job images partial only', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 2 + }) + ); + assert.equal(created.status, 202); + const createdBody = await created.json(); + + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 502); + const resultBody = await result.json(); + assert.equal(resultBody.error.code, 'upstream_unavailable'); + assert.match(resultBody.error.message, /最终图片 b64_json/); + assert.equal(resultBody.error.retryable, false); + assert.equal(resultBody.error.diagnostics.upstream_event_type, 'image_generation.partial_image'); + assert.equal(resultBody.error.diagnostics.partial_image_count, 1); + assert.equal(resultBody.error.diagnostics.retry_after_seconds, undefined); + assert.equal(JSON.stringify(resultBody).includes('job-images-partial-only'), false); + + const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { + params: Promise.resolve({ id: createdBody.job.id }) + }); + assert.equal(status.status, 200); + const statusBody = await status.json(); + assert.equal(statusBody.job.state, 'failed'); + assert.equal(statusBody.job.error.code, 'upstream_unavailable'); + assert.match(statusBody.job.error.message, /最终图片 b64_json/); + assert.equal(statusBody.job.error.retryable, false); + assert.equal(statusBody.job.error.diagnostics.upstream_event_type, 'image_generation.partial_image'); + assert.equal(statusBody.job.error.diagnostics.partial_image_count, 1); + assert.equal(statusBody.job.error.diagnostics.retry_after_seconds, undefined); + assert.equal(JSON.stringify(statusBody).includes('job-images-partial-only'), false); + } finally { + await upstream.close(); + } + }); + + it('creates a generate job that consumes Responses image_generation SSE and saves the final artifact', async () => { + const { createGenerateJob, getJobResult, getArtifactContent } = await loadAgentRoutes(); + let upstreamBody = ''; + const upstream = await startStreamingResponsesImageUpstream((body) => { + upstreamBody = body; + return [ + { + event: 'response.image_generation_call.partial_image', + data: { + type: 'response.image_generation_call.partial_image', + partial_image_b64: 'job-responses-partial-base64', + partial_image_index: 0 + } + }, + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + } + ]; + }); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob( + agentJobJsonRequest('route-job-responses-upstream-sse-key', { + prompt: 'agent job responses upstream sse', + image_backend: 'responses-image-generation', + streaming_strategy: 'responses-sse', + partial_images: 2 + }) + ); + assert.equal(created.status, 202); + const createdBody = await created.json(); + + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 200); + assert.notEqual(result.headers.get('content-type'), 'text/event-stream'); + const resultBody = await result.json(); + assert.equal(resultBody.request_id, createdBody.job.id); + assert.equal(resultBody.cached, false); + assert.equal(resultBody.images.length, 1); + assert.equal('b64_json' in resultBody.images[0], false); + assert.match(resultBody.images[0].content_url, /^\/api\/agent\/artifacts\/[^/]+\/content$/); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-5.4'); + assert.equal(upstreamJson.stream, true); + assert.equal((upstreamJson.tool_choice as Record).type, 'image_generation'); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].type, 'image_generation'); + assert.equal(tools[0].partial_images, 2); + + const artifactId = resultBody.images[0].content_url.split('/').at(-2); + assert.equal(typeof artifactId, 'string'); + const content = await getArtifactContent( + new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), + { params: Promise.resolve({ id: artifactId }) } + ); + assert.equal(content.status, 200); + assert.equal( + Buffer.compare(Buffer.from(await content.arrayBuffer()), Buffer.from(PNG_BASE64, 'base64')), + 0 + ); + } finally { + await upstream.close(); + } + }); + + it('fails Responses upstream SSE generate jobs when partial images arrive without a final image', async () => { + const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes(); + const upstream = await startStreamingResponsesImageUpstream(() => [ + { + event: 'response.image_generation_call.partial_image', + data: { + type: 'response.image_generation_call.partial_image', + partial_image_b64: 'job-responses-partial-only', + partial_image_index: 0 + } + } + ]); + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-5.4'; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const created = await createGenerateJob( + agentJobJsonRequest('route-job-responses-missing-final-key', { + prompt: 'agent job responses partial only', + image_backend: 'responses-image-generation', + streaming_strategy: 'responses-sse', + partial_images: 2 + }) + ); + assert.equal(created.status, 202); + const createdBody = await created.json(); + + const result = await waitForJobResult(getJobResult, createdBody.job.id); + assert.equal(result.status, 502); + const resultBody = await result.json(); + assert.equal(resultBody.error.code, 'upstream_unavailable'); + assert.match(resultBody.error.message, /最终图片 b64_json/); + assert.equal(resultBody.error.retryable, false); + assert.equal( + resultBody.error.diagnostics.upstream_event_type, + 'response.image_generation_call.partial_image' + ); + assert.equal(resultBody.error.diagnostics.partial_image_count, 1); + assert.equal(resultBody.error.diagnostics.retry_after_seconds, undefined); + assert.equal(JSON.stringify(resultBody).includes('job-responses-partial-only'), false); + + const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), { + params: Promise.resolve({ id: createdBody.job.id }) + }); + assert.equal(status.status, 200); + const statusBody = await status.json(); + assert.equal(statusBody.job.state, 'failed'); + assert.equal(statusBody.job.error.code, 'upstream_unavailable'); + assert.match(statusBody.job.error.message, /最终图片 b64_json/); + assert.equal(statusBody.job.error.retryable, false); + assert.equal( + statusBody.job.error.diagnostics.upstream_event_type, + 'response.image_generation_call.partial_image' + ); + assert.equal(statusBody.job.error.diagnostics.partial_image_count, 1); + assert.equal(statusBody.job.error.diagnostics.retry_after_seconds, undefined); + assert.equal(JSON.stringify(statusBody).includes('job-responses-partial-only'), false); + } finally { + await upstream.close(); + } + }); + it('reuses the running generate job for the same idempotency key', async () => { const { createGenerateJob, getJobResult } = await loadAgentRoutes(); let releaseUpstream: (() => void) | undefined; @@ -356,12 +1002,16 @@ describe('Agent route integration', () => { process.env.OPENAI_API_BASE_URL = upstream.baseUrl; try { - const first = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' })); + const first = await createGenerateJob( + agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' }) + ); assert.equal(first.status, 202); const firstBody = await first.json(); await waitFor(() => upstreamCalls === 1); - const conflict = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' })); + const conflict = await createGenerateJob( + agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' }) + ); assert.equal(conflict.status, 409); assert.equal((await conflict.json()).error.code, 'idempotency_conflict'); assert.equal(upstreamCalls, 1); @@ -386,7 +1036,9 @@ describe('Agent route integration', () => { process.env.OPENAI_API_BASE_URL = upstream.baseUrl; try { - const created = await createGenerateJob(agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' })); + const created = await createGenerateJob( + agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }) + ); assert.equal(created.status, 202); const createdBody = await created.json(); @@ -411,11 +1063,123 @@ describe('Agent route integration', () => { assert.equal(statusBody.job.error.upstream_status, 500); assert.equal(statusBody.job.error.diagnostics.upstream_status, 500); assert.equal(upstreamCalls > 0, true); + + const replay = await createGenerateJob( + agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }) + ); + assert.equal(replay.status, 202); + assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); + const replayBody = await replay.json(); + assert.equal(replayBody.job.id, createdBody.job.id); + assert.equal(replayBody.job.state, 'failed'); } finally { await upstream.close(); } }); + it('marks generate jobs as failed when completion state persistence fails', async () => { + const { createGenerateJob, getJob } = await loadAgentRoutes(); + const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); + const requestId = 'job-completion-failure-request'; + let failErrorCode: AgentErrorCode | undefined; + let saveCalls = 0; + const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + setAgentStateStoreFactoryForTests(() => ({ + async init() {}, + async recoverExpiredRequests() { + return 0; + }, + async purgeExpiredRequests() { + return 0; + }, + async beginRequest() { + return { + type: 'acquired', + record: { + requestId, + idempotencyKey: 'job-completion-failure-key', + requestHash: 'hash', + mode: 'generate', + status: 'running', + requestJson: { prompt: 'job completion persistence failure' }, + createdAt: '2026-05-12T00:00:00.000Z', + updatedAt: '2026-05-12T00:00:00.000Z', + expiresAt: '2099-05-13T00:00:00.000Z' + } + }; + }, + async refreshRequestLease() { + return true; + }, + async saveArtifacts() { + saveCalls += 1; + }, + async completeRequest() { + throw new Error('job completion persistence failed'); + }, + async failRequest(input: { requestId: string; error: { error: { code: AgentErrorCode } } }) { + assert.equal(input.requestId, requestId); + failErrorCode = input.error.error.code; + }, + async getRequest(id: string) { + if (id !== requestId || !failErrorCode) return undefined; + return { + requestId, + idempotencyKey: 'job-completion-failure-key', + requestHash: 'hash', + mode: 'generate', + status: 'failed', + requestJson: { prompt: 'job completion persistence failure' }, + errorJson: { + error: { + code: failErrorCode, + message: '保存请求完成状态失败。', + retryable: true, + request_id: requestId + } + }, + createdAt: '2026-05-12T00:00:00.000Z', + updatedAt: '2026-05-12T00:00:01.000Z', + expiresAt: '2099-05-13T00:00:00.000Z' + }; + }, + async getArtifact() { + return undefined; + }, + async listArtifactsForRequest() { + return []; + }, + async deleteArtifact() { + return false; + } + })); + + const originalConsoleError = console.error; + console.error = () => {}; + try { + const created = await createGenerateJob( + agentJobJsonRequest('job-completion-failure-key', { prompt: 'job completion persistence failure' }) + ); + assert.equal(created.status, 202); + await waitFor(() => failErrorCode === 'unexpected_error'); + assert.equal(saveCalls, 1); + + const status = await getJob(new Request(`http://localhost/api/agent/jobs/${requestId}`), { + params: Promise.resolve({ id: requestId }) + }); + assert.equal(status.status, 200); + const statusBody = await status.json(); + assert.equal(statusBody.job.state, 'failed'); + assert.equal(statusBody.job.error.code, 'unexpected_error'); + assert.equal(statusBody.job.error.retryable, false); + } finally { + console.error = originalConsoleError; + await upstream.close(); + } + }); + it('keeps a long-running generate job leased while the upstream call is still active', async () => { process.env.AGENT_REQUEST_LEASE_MS = '200'; process.env.AGENT_RECOVERY_INTERVAL_MS = '50'; @@ -433,7 +1197,9 @@ describe('Agent route integration', () => { process.env.OPENAI_API_BASE_URL = upstream.baseUrl; try { - const created = await createGenerateJob(agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' })); + const created = await createGenerateJob( + agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' }) + ); assert.equal(created.status, 202); const createdBody = await created.json(); await waitFor(() => upstreamCalls === 1); @@ -457,7 +1223,9 @@ describe('Agent route integration', () => { it('returns structured errors for missing and expired jobs', async () => { const { getJob, getJobResult } = await loadAgentRoutes(); - const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime'); + const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import( + '@/lib/agent-state-runtime' + ); const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), { params: Promise.resolve({ id: 'missing-job' }) @@ -543,13 +1311,45 @@ describe('Agent route integration', () => { await upstream.close(); }); + it('aborts Agent edit upstream calls when the client request signal aborts', async () => { + const { editImage } = await loadAgentRoutes(); + const upstream = await startHangingImageEditUpstream(); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + const abortController = new AbortController(); + + try { + const responsePromise = editImage( + agentEditRequest('route-edit-abort-key', 'agent edit abort', {}, 'path', { + signal: abortController.signal + }) + ); + await waitFor(() => upstream.requests === 1); + abortController.abort(); + + const response = await Promise.race([ + responsePromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error('Agent edit upstream call did not abort')), 1500) + ) + ]); + + assert.notEqual(response.status, 200); + } finally { + abortController.abort(); + await upstream.close(); + } + }); + it('returns field-level errors for invalid edit multipart requests', async () => { const { editImage } = await loadAgentRoutes(); const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] })); process.env.OPENAI_API_KEY = 'test-key'; process.env.OPENAI_API_BASE_URL = upstream.baseUrl; - const response = await editImage(agentEditRequest('route-edit-validation-key', 'agent edit invalid', {}, 'url')); + const response = await editImage( + agentEditRequest('route-edit-validation-key', 'agent edit invalid', {}, 'url') + ); assert.equal(response.status, 422); const body = await response.json(); @@ -559,6 +1359,93 @@ describe('Agent route integration', () => { await upstream.close(); }); + it('rejects high-resolution Agent edit requests before contacting upstream', async () => { + const { editImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await editImage( + agentEditRequest('route-edit-high-resolution-key', 'high resolution edit', {}, { size: '3072x2048' }) + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.message, /\/api\/images/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('rejects high-resolution Agent edit requests before reading files or API credentials', async () => { + const { editImage } = await loadAgentRoutes(); + delete process.env.OPENAI_API_KEY; + delete process.env.OPENAI_API_BASE_URL; + + const formData = new FormData(); + formData.append('prompt', 'high resolution edit without file'); + formData.append('model', 'gpt-image-2'); + formData.append('size', '3072x2048'); + formData.append('response_mode', 'path'); + + const response = await editImage( + new Request('http://localhost/api/agent/images/edit', { + method: 'POST', + headers: { + 'Idempotency-Key': 'route-edit-high-resolution-no-file-key' + }, + body: formData + }) + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.message, /\/api\/images/); + assert.equal(body.error.details?.fields?.image_0, undefined); + assert.doesNotMatch(body.error.message, /API Key|图片文件/); + }); + + it('rejects auto-size Agent edit when the uploaded source image is high resolution', async () => { + const { editImage } = await loadAgentRoutes(); + let upstreamCalls = 0; + const upstream = await startImageUpstream(() => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + const response = await editImage( + agentEditRequest( + 'route-edit-auto-high-resolution-source-key', + 'auto high resolution source', + {}, + { + size: 'auto', + image_0: createPngWithDimensions(3072, 2048) + } + ) + ); + + assert.equal(response.status, 422); + const body = await response.json(); + assert.equal(body.error.code, 'validation_error'); + assert.match(body.error.message, /\/api\/images/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + it('returns validation errors for non-multipart edit requests', async () => { const { editImage } = await loadAgentRoutes(); @@ -627,6 +1514,7 @@ describe('Agent route integration', () => { const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics')); assert.equal(replay.status, 502); assert.equal(replay.headers.get('x-idempotent-replay'), 'true'); + assert.equal(replay.headers.get('x-request-id'), body.error.request_id); const replayBody = await replay.json(); assert.equal(replayBody.error.code, 'upstream_unavailable'); assert.equal(replayBody.error.retryable, false); @@ -703,7 +1591,9 @@ describe('Agent route integration', () => { const originalConsoleError = console.error; console.error = () => {}; try { - const response = await editImage(agentEditRequest('edit-completion-failure-key', 'state completion failure')); + const response = await editImage( + agentEditRequest('edit-completion-failure-key', 'state completion failure') + ); assert.equal(response.status, 500); const body = await response.json(); @@ -727,14 +1617,21 @@ describe('Agent route integration', () => { process.env.AGENT_API_TOKEN = 'artifact-token'; const generated = await generateImage( - agentJsonRequest('artifact-auth-key', { prompt: 'artifact auth' }, { Authorization: 'Bearer artifact-token' }) + agentJsonRequest( + 'artifact-auth-key', + { prompt: 'artifact auth' }, + { Authorization: 'Bearer artifact-token' } + ) ); const body = await generated.json(); const artifactId = body.images[0].id; - const denied = await getArtifactContent(new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), { - params: Promise.resolve({ id: artifactId }) - }); + const denied = await getArtifactContent( + new Request(`http://localhost/api/agent/artifacts/${artifactId}/content`), + { + params: Promise.resolve({ id: artifactId }) + } + ); assert.equal(denied.status, 401); assert.equal((await denied.json()).error.code, 'unauthorized'); @@ -770,7 +1667,11 @@ describe('Agent route integration', () => { assert.equal((await deleted.json()).deleted, true); const replayAfterDelete = await generateImage( - agentJsonRequest('artifact-auth-key', { prompt: 'artifact auth' }, { Authorization: 'Bearer artifact-token' }) + agentJsonRequest( + 'artifact-auth-key', + { prompt: 'artifact auth' }, + { Authorization: 'Bearer artifact-token' } + ) ); assert.equal(replayAfterDelete.status, 404); assert.equal((await replayAfterDelete.json()).error.code, 'artifact_not_found'); @@ -785,7 +1686,9 @@ describe('Agent route integration', () => { process.env.OPENAI_API_BASE_URL = upstream.baseUrl; try { - const generated = await generateImage(agentJsonRequest('artifact-missing-content-key', { prompt: 'missing content' })); + const generated = await generateImage( + agentJsonRequest('artifact-missing-content-key', { prompt: 'missing content' }) + ); const body = await generated.json(); const image = body.images[0]; await rm(readStoredArtifactFilepath(image.id), { force: true }); @@ -868,7 +1771,9 @@ describe('Agent route integration', () => { const originalConsoleError = console.error; console.error = () => {}; try { - const response = await generateImage(agentJsonRequest('completion-failure-key', { prompt: 'state completion failure' })); + const response = await generateImage( + agentJsonRequest('completion-failure-key', { prompt: 'state completion failure' }) + ); assert.equal(response.status, 500); const body = await response.json(); @@ -944,7 +1849,9 @@ describe('Agent route integration', () => { const originalConsoleError = console.error; console.error = () => {}; try { - const response = await generateImage(agentJsonRequest('artifact-save-failure-key', { prompt: 'artifact save failure' })); + const response = await generateImage( + agentJsonRequest('artifact-save-failure-key', { prompt: 'artifact save failure' }) + ); assert.equal(response.status, 500); const body = await response.json(); @@ -961,57 +1868,67 @@ describe('Agent route integration', () => { const livePostgresUrl = process.env.AGENT_POSTGRES_TEST_DATABASE_URL; -describe('Agent route PostgreSQL integration', { skip: livePostgresUrl ? false : 'AGENT_POSTGRES_TEST_DATABASE_URL is not set' }, () => { - it('allows only one upstream winner for concurrent identical idempotency requests', async () => { - assert.ok(livePostgresUrl); - const { generateImage } = await loadAgentRoutes(); - const schemaName = `agent_route_${Date.now().toString(36)}`; - const pool = new Pool({ connectionString: livePostgresUrl }); - process.env.AGENT_STATE_BACKEND = 'postgres'; - process.env.AGENT_DATABASE_URL = `${livePostgresUrl}${livePostgresUrl.includes('?') ? '&' : '?'}options=-c%20search_path%3D${schemaName}`; - process.env.AGENT_REQUEST_LEASE_MS = '60000'; - const admin = await pool.connect(); - let releaseUpstream: (() => void) | undefined; - let upstreamCalls = 0; - const upstream = await startImageUpstream(async () => { - upstreamCalls += 1; - await new Promise((resolve) => { - releaseUpstream = resolve; +describe( + 'Agent route PostgreSQL integration', + { skip: livePostgresUrl ? false : 'AGENT_POSTGRES_TEST_DATABASE_URL is not set' }, + () => { + it('allows only one upstream winner for concurrent identical idempotency requests', async () => { + assert.ok(livePostgresUrl); + const { generateImage } = await loadAgentRoutes(); + const schemaName = `agent_route_${Date.now().toString(36)}`; + const pool = new Pool({ connectionString: livePostgresUrl }); + process.env.AGENT_STATE_BACKEND = 'postgres'; + process.env.AGENT_DATABASE_URL = `${livePostgresUrl}${livePostgresUrl.includes('?') ? '&' : '?'}options=-c%20search_path%3D${schemaName}`; + process.env.AGENT_REQUEST_LEASE_MS = '60000'; + const admin = await pool.connect(); + let releaseUpstream: (() => void) | undefined; + let upstreamCalls = 0; + const upstream = await startImageUpstream(async () => { + upstreamCalls += 1; + await new Promise((resolve) => { + releaseUpstream = resolve; + }); + return { data: [{ b64_json: PNG_BASE64 }] }; }); - return { data: [{ b64_json: PNG_BASE64 }] }; + process.env.OPENAI_API_KEY = 'test-key'; + process.env.OPENAI_API_BASE_URL = upstream.baseUrl; + + try { + await admin.query(`CREATE SCHEMA "${schemaName}"`); + const firstRequest = generateImage( + agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }) + ); + await waitFor(() => upstreamCalls === 1); + + const second = await generateImage( + agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }) + ); + assert.equal(second.status, 409); + const secondBody = await second.json(); + assert.equal(secondBody.error.code, 'request_in_progress'); + assert.equal(secondBody.error.retryable, true); + assert.equal(second.headers.has('retry-after'), true); + + releaseUpstream?.(); + const first = await firstRequest; + assert.equal(first.status, 200); + + const replay = await generateImage( + agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' }) + ); + assert.equal(replay.status, 200); + assert.equal((await replay.json()).cached, true); + assert.equal(upstreamCalls, 1); + } finally { + releaseUpstream?.(); + await upstream.close(); + await admin.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`); + admin.release(); + await pool.end(); + } }); - process.env.OPENAI_API_KEY = 'test-key'; - process.env.OPENAI_API_BASE_URL = upstream.baseUrl; - - try { - await admin.query(`CREATE SCHEMA "${schemaName}"`); - const firstRequest = generateImage(agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' })); - await waitFor(() => upstreamCalls === 1); - - const second = await generateImage(agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' })); - assert.equal(second.status, 409); - const secondBody = await second.json(); - assert.equal(secondBody.error.code, 'request_in_progress'); - assert.equal(secondBody.error.retryable, true); - assert.equal(second.headers.has('retry-after'), true); - - releaseUpstream?.(); - const first = await firstRequest; - assert.equal(first.status, 200); - - const replay = await generateImage(agentJsonRequest('pg-route-concurrent-key', { prompt: 'pg concurrent' })); - assert.equal(replay.status, 200); - assert.equal((await replay.json()).cached, true); - assert.equal(upstreamCalls, 1); - } finally { - releaseUpstream?.(); - await upstream.close(); - await admin.query(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`); - admin.release(); - await pool.end(); - } - }); -}); + } +); async function loadAgentRoutes() { const { resetAgentStateStoreForTests } = await import('@/lib/agent-state-runtime'); @@ -1027,18 +1944,28 @@ async function loadAgentRoutes() { const jobRoute = await import('./jobs/[id]/route'); const jobResultRoute = await import('./jobs/[id]/result/route'); return { - getCapabilities: capabilitiesRoute.GET, - generateImage: generateRoute.POST, - editImage: editRoute.POST, - createGenerateJob: createGenerateJobRoute.POST, - getJob: jobRoute.GET, - getJobResult: jobResultRoute.GET, - getArtifact: artifactRoute.GET, - deleteArtifact: artifactRoute.DELETE, - getArtifactContent: artifactContentRoute.GET + getCapabilities: () => capabilitiesRoute.GET(), + generateImage: (request: Request) => generateRoute.POST(asNextRequest(request)), + editImage: (request: Request) => editRoute.POST(asNextRequest(request)), + createGenerateJob: (request: Request) => createGenerateJobRoute.POST(asNextRequest(request)), + getJob: (request: Request, context: AgentRouteContext) => jobRoute.GET(asNextRequest(request), context), + getJobResult: (request: Request, context: AgentRouteContext) => + jobResultRoute.GET(asNextRequest(request), context), + getArtifact: (request: Request, context: AgentRouteContext) => + artifactRoute.GET(asNextRequest(request), context), + deleteArtifact: (request: Request, context: AgentRouteContext) => + artifactRoute.DELETE(asNextRequest(request), context), + getArtifactContent: (request: Request, context: AgentRouteContext) => + artifactContentRoute.GET(asNextRequest(request), context) }; } +type AgentRouteContext = { params: Promise<{ id: string }> }; + +function asNextRequest(request: Request): NextRequest { + return request as unknown as NextRequest; +} + function agentJsonRequest(idempotencyKey: string, body: Record, headers: Record = {}) { return new Request('http://localhost/api/agent/images/generate', { method: 'POST', @@ -1051,7 +1978,11 @@ function agentJsonRequest(idempotencyKey: string, body: Record, }); } -function agentJobJsonRequest(idempotencyKey: string, body: Record, headers: Record = {}) { +function agentJobJsonRequest( + idempotencyKey: string, + body: Record, + headers: Record = {} +) { return new Request('http://localhost/api/agent/jobs/images/generate', { method: 'POST', headers: { @@ -1063,28 +1994,55 @@ function agentJobJsonRequest(idempotencyKey: string, body: Record = {}, - responseMode = 'path' + responseModeOrFields: string | AgentEditRequestFields = 'path', + options: { signal?: AbortSignal } = {} ) { + const fields: AgentEditRequestFields = + typeof responseModeOrFields === 'string' + ? { response_mode: responseModeOrFields } + : { response_mode: 'path', ...responseModeOrFields }; + const imageBuffer = fields.image_0 ?? Buffer.from(PNG_BASE64, 'base64'); const formData = new FormData(); formData.append('prompt', prompt); formData.append('model', 'gpt-image-2'); - formData.append('response_mode', responseMode); - formData.append('image_0', new File([Buffer.from(PNG_BASE64, 'base64')], 'input.png', { type: 'image/png' })); + for (const [key, value] of Object.entries(fields)) { + if (key === 'image_0') continue; + if (typeof value !== 'string') { + throw new TypeError(`Agent edit test field ${key} must be a string.`); + } + formData.append(key, value); + } + formData.append('image_0', new File([imageBuffer], 'input.png', { type: 'image/png' })); return new Request('http://localhost/api/agent/images/edit', { method: 'POST', headers: { 'Idempotency-Key': idempotencyKey, ...headers }, - body: formData + body: formData, + signal: options.signal }); } -async function startImageUpstream(handler: () => unknown | Promise): Promise<{ baseUrl: string; close: () => Promise }> { +function createPngWithDimensions(width: number, height: number): Buffer { + const buffer = Buffer.from(PNG_BASE64, 'base64'); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + +async function startImageUpstream( + handler: (body: string, url: string) => unknown | Promise +): Promise<{ baseUrl: string; close: () => Promise }> { const server = http.createServer(async (request, response) => { if ( request.method !== 'POST' || @@ -1094,9 +2052,11 @@ async function startImageUpstream(handler: () => unknown | Promise): Pr response.end(JSON.stringify({ error: { message: 'not found' } })); return; } - request.resume(); + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); try { - const body = await handler(); + const body = await handler(Buffer.concat(chunks).toString('utf8'), request.url || ''); response.writeHead(200, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); } catch (error) { @@ -1114,6 +2074,111 @@ async function startImageUpstream(handler: () => unknown | Promise): Pr }; } +async function startHangingImageEditUpstream(): Promise<{ + baseUrl: string; + readonly requests: number; + close: () => Promise; +}> { + let requests = 0; + const sockets = new Set(); + const server = http.createServer(async (request, response) => { + if (request.method === 'POST' && request.url?.endsWith('/images/edits')) { + requests += 1; + request.resume(); + return; + } + request.resume(); + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + }); + server.on('connection', (socket) => { + sockets.add(socket); + socket.on('close', () => sockets.delete(socket)); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + get requests() { + return requests; + }, + close: () => + new Promise((resolve, reject) => { + for (const socket of sockets) socket.destroy(); + server.close((error) => (error ? reject(error) : resolve())); + }) + }; +} + +async function startStreamingImageUpstream( + handler: ( + body: string + ) => Array<{ event?: string; data: unknown }> | Promise> +): Promise<{ baseUrl: string; close: () => Promise }> { + const server = http.createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/images/generations')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const events = await handler(Buffer.concat(chunks).toString('utf8')); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const event of events) { + if (event.event) { + response.write(`event: ${event.event}\n`); + } + response.write(`data: ${JSON.stringify(event.data)}\n\n`); + } + response.write('data: [DONE]\n\n'); + response.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} + +async function startStreamingResponsesImageUpstream( + handler: ( + body: string + ) => Array<{ event?: string; data: unknown }> | Promise> +): Promise<{ baseUrl: string; close: () => Promise }> { + const server = http.createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const events = await handler(Buffer.concat(chunks).toString('utf8')); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const event of events) { + if (event.event) { + response.write(`event: ${event.event}\n`); + } + response.write(`data: ${JSON.stringify(event.data)}\n\n`); + } + response.write('data: [DONE]\n\n'); + response.end(); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))) + }; +} + function readStoredResponseJson(idempotencyKey: string): string { const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true }); try { @@ -1130,7 +2195,9 @@ function readStoredResponseJson(idempotencyKey: string): string { function readStoredArtifactFilepath(id: string): string { const db = new Database(path.join(tempDir, 'agent.sqlite'), { readonly: true }); try { - const row = db.prepare('SELECT filepath FROM agent_artifacts WHERE id = ?').get(id) as { filepath: string } | undefined; + const row = db.prepare('SELECT filepath FROM agent_artifacts WHERE id = ?').get(id) as + | { filepath: string } + | undefined; assert.ok(row); return row.filepath; } finally { @@ -1140,7 +2207,9 @@ function readStoredArtifactFilepath(id: string): string { async function listGeneratedImageFiles(): Promise { try { - return (await readdir(path.join(tempDir, 'generated-images'))).filter((entry) => /\.(png|jpe?g|webp)$/i.test(entry)); + return (await readdir(path.join(tempDir, 'generated-images'))).filter((entry) => + /\.(png|jpe?g|webp)$/i.test(entry) + ); } catch { return []; } @@ -1163,10 +2232,7 @@ async function waitFor(predicate: () => boolean): Promise { } async function waitForJobResult( - getJobResult: ( - request: Request, - context: { params: Promise<{ id: string }> } - ) => Promise, + getJobResult: (request: Request, context: { params: Promise<{ id: string }> }) => Promise, id: string ): Promise { for (let attempt = 0; attempt < 200; attempt += 1) { diff --git a/src/app/api/agent/capabilities/route.ts b/src/app/api/agent/capabilities/route.ts index 3c960e458324472bcbd73943961b953285dd93a9..629bb8aa81dc2b643de6444c8dd53c6ad77d5b9a 100644 --- a/src/app/api/agent/capabilities/route.ts +++ b/src/app/api/agent/capabilities/route.ts @@ -16,6 +16,8 @@ function readPublicCapabilitiesEnv(): Record { AGENT_DB_PASSWORD_FILE: process.env.AGENT_DB_PASSWORD_FILE ? 'configured' : undefined, AGENT_REQUEST_TTL_SECONDS: process.env.AGENT_REQUEST_TTL_SECONDS, AGENT_PUBLIC_BASE_URL: process.env.AGENT_PUBLIC_BASE_URL, + ENABLE_RESPONSES_IMAGE_BACKEND: process.env.ENABLE_RESPONSES_IMAGE_BACKEND, + OPENAI_RESPONSES_API_MODEL: process.env.OPENAI_RESPONSES_API_MODEL?.trim() ? 'configured' : undefined, NEXT_PUBLIC_IMAGE_STORAGE_MODE: process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE, VERCEL: process.env.VERCEL }; diff --git a/src/app/api/agent/images/edit/route.ts b/src/app/api/agent/images/edit/route.ts index c1868a1a8234b838e169e237c5249942d1ee5927..efde0a962bdfef079fef68095e79cdd348ac689e 100644 --- a/src/app/api/agent/images/edit/route.ts +++ b/src/app/api/agent/images/edit/route.ts @@ -1,4 +1,7 @@ +import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts'; +import { assertAgentAuthorized } from '@/lib/agent-auth'; import { + assertAgentEditRouteAllowedFromFormData, buildEditRequestHashFromSnapshot, completeAgentExecutionState, createArtifactPersistenceError, @@ -12,11 +15,14 @@ import { saveAgentExecutionArtifacts, snapshotAgentEditFormData } from '@/lib/agent-image-service'; -import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts'; -import { AgentApiError, agentErrorResponse, normalizeAgentError, storedAgentErrorResponse } from '@/lib/api-error-response'; -import { assertAgentAuthorized } from '@/lib/agent-auth'; import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime'; import { createRequestId } from '@/lib/agent-state-store'; +import { + AgentApiError, + agentErrorResponse, + normalizeAgentError, + storedAgentErrorResponse +} from '@/lib/api-error-response'; import { appLogger } from '@/lib/app-logger'; import { NextRequest, NextResponse } from 'next/server'; @@ -25,6 +31,7 @@ export async function POST(request: NextRequest) { try { assertAgentAuthorized(request.headers); const formData = await parseAgentEditFormData(request); + await assertAgentEditRouteAllowedFromFormData(formData); const idempotencyKey = readIdempotencyKey(request.headers); const requestSnapshot = await snapshotAgentEditFormData(formData); const store = await ensureAgentStateStoreReady(); @@ -45,7 +52,10 @@ export async function POST(request: NextRequest) { } if (beginResult.type === 'failed') { requestId = beginResult.record.requestId; - return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' }); + return storedAgentErrorResponse(beginResult.error, { + 'X-Idempotent-Replay': 'true', + 'X-Request-Id': requestId + }); } if (beginResult.type === 'conflict') { throw new AgentApiError({ @@ -71,7 +81,8 @@ export async function POST(request: NextRequest) { headers: request.headers, requestId, idempotencyKey, - cached: false + cached: false, + abortSignal: request.signal }).catch(async (error) => { const errorBody = errorToAgentErrorBody(error, requestId); await store.failRequest({ requestId, error: errorBody }); diff --git a/src/app/api/agent/images/generate/route.ts b/src/app/api/agent/images/generate/route.ts index 2374fea65a8e3a28e34bcd89cf87a3e2e1f80173..11c1faca7054c7235db8babee269ff2e0b5d4b44 100644 --- a/src/app/api/agent/images/generate/route.ts +++ b/src/app/api/agent/images/generate/route.ts @@ -1,3 +1,5 @@ +import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts'; +import { assertAgentAuthorized } from '@/lib/agent-auth'; import { buildGenerateRequestHash, completeAgentExecutionState, @@ -11,11 +13,14 @@ import { readIdempotencyKey, saveAgentExecutionArtifacts } from '@/lib/agent-image-service'; -import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts'; -import { AgentApiError, agentErrorResponse, normalizeAgentError, storedAgentErrorResponse } from '@/lib/api-error-response'; -import { assertAgentAuthorized } from '@/lib/agent-auth'; import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime'; import { createRequestId } from '@/lib/agent-state-store'; +import { + AgentApiError, + agentErrorResponse, + normalizeAgentError, + storedAgentErrorResponse +} from '@/lib/api-error-response'; import { appLogger } from '@/lib/app-logger'; import { NextRequest, NextResponse } from 'next/server'; @@ -43,7 +48,10 @@ export async function POST(request: NextRequest) { } if (beginResult.type === 'failed') { requestId = beginResult.record.requestId; - return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' }); + return storedAgentErrorResponse(beginResult.error, { + 'X-Idempotent-Replay': 'true', + 'X-Request-Id': requestId + }); } if (beginResult.type === 'conflict') { throw new AgentApiError({ @@ -69,7 +77,8 @@ export async function POST(request: NextRequest) { headers: request.headers, requestId, idempotencyKey, - cached: false + cached: false, + abortSignal: request.signal }).catch(async (error) => { const errorBody = errorToAgentErrorBody(error, requestId); await store.failRequest({ requestId, error: errorBody }); diff --git a/src/app/api/agent/jobs/[id]/result/route.ts b/src/app/api/agent/jobs/[id]/result/route.ts index bc3e91092a34ba3248a639a8b3fc1f13ff55eace..416503ef78a715bb7f8dd101bac3f8ff686ba337 100644 --- a/src/app/api/agent/jobs/[id]/result/route.ts +++ b/src/app/api/agent/jobs/[id]/result/route.ts @@ -1,7 +1,7 @@ import { assertReadableJobRecord, readAgentJobState, - readCompletedJobResult + readJobResult } from '@/lib/agent-job-service'; import { AgentApiError, @@ -26,7 +26,7 @@ export async function GET(request: NextRequest, context: RouteContext) { const store = await ensureAgentStateStoreReady(); const record = assertReadableJobRecord(await store.getRequest(id), id); requestId = record.requestId; - const result = await readCompletedJobResult(store, record); + const result = await readJobResult(store, record); if (result.type === 'response') { return NextResponse.json(result.response, { headers: { 'X-Request-Id': requestId } }); } diff --git a/src/app/api/agent/jobs/images/generate/route.ts b/src/app/api/agent/jobs/images/generate/route.ts index 33cabf8c5ed5b6ca42ee4f2679b304c2dbf8429e..c2b4bd5adb9fb09e16e340678dfaf9ce0c210dd5 100644 --- a/src/app/api/agent/jobs/images/generate/route.ts +++ b/src/app/api/agent/jobs/images/generate/route.ts @@ -34,6 +34,7 @@ export async function POST(request: NextRequest) { if (beginResult.type === 'replay' || beginResult.type === 'failed') { requestId = beginResult.record.requestId; return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), { + status: 202, headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': requestId } }); } diff --git a/src/app/api/auth-status/route.test.ts b/src/app/api/auth-status/route.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb87bac8fe1719d0a1d40fc929456f7215b5b93a --- /dev/null +++ b/src/app/api/auth-status/route.test.ts @@ -0,0 +1,24 @@ +import { GET } from './route'; +import assert from 'node:assert/strict'; +import { afterEach, describe, it } from 'node:test'; + +const originalAppPassword = process.env.APP_PASSWORD; + +afterEach(() => { + if (originalAppPassword === undefined) { + delete process.env.APP_PASSWORD; + } else { + process.env.APP_PASSWORD = originalAppPassword; + } +}); + +describe('GET /api/auth-status', () => { + it('treats blank APP_PASSWORD as disabled', async () => { + process.env.APP_PASSWORD = ' '; + + const response = await GET(); + const body = (await response.json()) as { passwordRequired?: boolean }; + + assert.equal(body.passwordRequired, false); + }); +}); diff --git a/src/app/api/auth-status/route.ts b/src/app/api/auth-status/route.ts index 6ec21d81e79f6fc92064c472c94550a732beb0ef..f0f2c28aa85c2191ccdfc08fad26c1b2832877bd 100644 --- a/src/app/api/auth-status/route.ts +++ b/src/app/api/auth-status/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; export async function GET() { - const appPasswordSet = !!process.env.APP_PASSWORD; + const appPasswordSet = Boolean(process.env.APP_PASSWORD?.trim()); return NextResponse.json({ passwordRequired: appPasswordSet }); } diff --git a/src/app/api/auth-verify/route.test.ts b/src/app/api/auth-verify/route.test.ts index 2822c82b9adcfc0f586349e7b3e03980f7c7b176..f40b4ba32a7139557c613f84723998aa99005b7a 100644 --- a/src/app/api/auth-verify/route.test.ts +++ b/src/app/api/auth-verify/route.test.ts @@ -16,6 +16,21 @@ afterEach(() => { }); describe('POST /api/auth-verify', () => { + it('treats blank APP_PASSWORD as disabled', async () => { + process.env.APP_PASSWORD = ' '; + const request = new NextRequest('http://localhost/api/auth-verify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}) + }); + + const response = await POST(request); + const result = (await response.json()) as { authenticated?: boolean; passwordRequired?: boolean }; + + assert.equal(response.status, 200); + assert.deepEqual(result, { authenticated: true, passwordRequired: false }); + }); + it('returns a page access code error code for invalid access-code hashes', async () => { process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; const request = new NextRequest('http://localhost/api/auth-verify', { diff --git a/src/app/api/auth-verify/route.ts b/src/app/api/auth-verify/route.ts index 38727b1d5c54843fe9e63a17ad432c505cb47fd1..4e9afc6faae91dc4c4aba339ef564412a31413a0 100644 --- a/src/app/api/auth-verify/route.ts +++ b/src/app/api/auth-verify/route.ts @@ -7,7 +7,7 @@ type AuthVerifyRequestBody = { }; export async function POST(request: NextRequest) { - const appPassword = process.env.APP_PASSWORD; + const appPassword = process.env.APP_PASSWORD?.trim(); if (!appPassword) { return NextResponse.json({ authenticated: true, passwordRequired: false }); } diff --git a/src/app/api/image-delete/route.ts b/src/app/api/image-delete/route.ts index b13a95ca764075fbf64433575426566c7c0ef90d..2909218cd23ee858bcfe26a408177ce36d6b28de 100644 --- a/src/app/api/image-delete/route.ts +++ b/src/app/api/image-delete/route.ts @@ -1,7 +1,7 @@ -import fs from 'fs/promises'; import { appLogger } from '@/lib/app-logger'; import { isValidImageFilename } from '@/lib/image-request-utils'; import { outputDir, verifyPasswordHash } from '@/lib/server-runtime'; +import fs from 'fs/promises'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; @@ -21,7 +21,7 @@ export async function POST(request: NextRequest) { try { requestBody = await request.json(); - const appPassword = process.env.APP_PASSWORD; + const appPassword = process.env.APP_PASSWORD?.trim(); if (appPassword) { const clientPasswordHash = requestBody.passwordHash; diff --git a/src/app/api/image/[filename]/route.ts b/src/app/api/image/[filename]/route.ts index 97d312b3002471390c8be46b3f8424f9467d9393..3f47aff6fa1a02f311b46c31765f8ed007d01c47 100644 --- a/src/app/api/image/[filename]/route.ts +++ b/src/app/api/image/[filename]/route.ts @@ -1,14 +1,14 @@ -import fs from 'fs/promises'; import { RequestValidationError, isValidImageFilename } from '@/lib/image-request-utils'; import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; import { outputDir, verifyAccessToken } from '@/lib/server-runtime'; +import fs from 'fs/promises'; import { lookup } from 'mime-types'; import { NextRequest, NextResponse } from 'next/server'; import path from 'path'; export async function GET(request: NextRequest, { params }: { params: Promise<{ filename: string }> }) { const { filename } = await params; - const appPassword = process.env.APP_PASSWORD; + const appPassword = process.env.APP_PASSWORD?.trim(); const accessToken = request.cookies.get('gptImageAccess')?.value; if (!verifyAccessToken(accessToken, appPassword)) { return NextResponse.json( diff --git a/src/app/api/images/route-test-helpers.ts b/src/app/api/images/route-test-helpers.ts index af1c2e4e6bbb136ea04d900bb0a8b5d92c2bc836..9f68ecf501eff3c9d3d69e24c8f2ca47b72fea44 100644 --- a/src/app/api/images/route-test-helpers.ts +++ b/src/app/api/images/route-test-helpers.ts @@ -1,33 +1,40 @@ -export { readSseEvents } from '@/lib/sse-test-utils'; import type { NextRequest } from 'next/server'; import assert from 'node:assert/strict'; import http from 'node:http'; +export { readSseEvents } from '@/lib/sse-test-utils'; + export const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; export function imageFormRequest(input: { apiBaseUrl: string; apiKey: string; - stream: boolean; + stream?: boolean; mode?: 'generate' | 'edit'; - imageBackend?: 'images' | 'responses'; + imageBackend?: 'images' | 'responses' | 'images-api' | 'responses-image-generation'; + imageStreamingStrategy?: 'off' | 'auto' | 'openai-sse' | 'newapi-keepalive-sse' | 'responses-sse' | 'force-sse'; + size?: string; n?: string; responsesModel?: string; + clientRequestId?: string; }): NextRequest { const formData = new FormData(); formData.append('mode', input.mode || 'generate'); formData.append('prompt', 'route stream contract'); formData.append('model', 'gpt-image-2'); formData.append('n', input.n || '1'); - formData.append('size', '1024x1024'); + formData.append('size', input.size || '1024x1024'); formData.append('output_format', 'png'); formData.append('apiBaseUrl', input.apiBaseUrl); formData.append('apiKey', input.apiKey); - formData.append('clientRequestId', 'client-route-stream'); + formData.append('clientRequestId', input.clientRequestId ?? 'client-route-stream'); if (input.imageBackend) { formData.append('imageBackend', input.imageBackend); } + if (input.imageStreamingStrategy) { + formData.append('imageStreamingStrategy', input.imageStreamingStrategy); + } if (input.responsesModel) { formData.append('responsesModel', input.responsesModel); } @@ -77,6 +84,26 @@ export async function startStreamingImageUpstream( return listen(server); } +export async function startImagesJsonUpstream( + handler: (body: string, url: string) => Promise +): Promise<{ baseUrl: string; close: () => Promise }> { + const server = http.createServer(async (request, response) => { + const isImagePath = request.url?.endsWith('/images/generations') || request.url?.endsWith('/images/edits'); + if (request.method !== 'POST' || !isImagePath) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const payload = await handler(Buffer.concat(chunks).toString('utf8'), request.url || ''); + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(payload)); + }); + return listen(server); +} + export async function startResponsesImageUpstream( handler: (body: string) => Promise ): Promise<{ baseUrl: string; close: () => Promise }> { @@ -96,6 +123,32 @@ export async function startResponsesImageUpstream( return listen(server); } +export async function startStreamingResponsesImageUpstream( + handler: (body: string) => Promise> +): Promise<{ baseUrl: string; close: () => Promise }> { + const server = http.createServer(async (request, response) => { + if (request.method !== 'POST' || !request.url?.endsWith('/responses')) { + response.writeHead(404, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify({ error: { message: 'not found' } })); + return; + } + const chunks: Buffer[] = []; + request.on('data', (chunk: Buffer) => chunks.push(chunk)); + await new Promise((resolve) => request.on('end', resolve)); + const events = await handler(Buffer.concat(chunks).toString('utf8')); + response.writeHead(200, { 'Content-Type': 'text/event-stream' }); + for (const event of events) { + if (event.event) { + response.write(`event: ${event.event}\n`); + } + response.write(`data: ${JSON.stringify(event.data)}\n\n`); + } + response.write('data: [DONE]\n\n'); + response.end(); + }); + return listen(server); +} + async function listen(server: http.Server): Promise<{ baseUrl: string; close: () => Promise }> { await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); diff --git a/src/app/api/images/route.test.ts b/src/app/api/images/route.test.ts index eb226f7baa8048ce705c1adea5c7ea9bcfae24d7..3331ae07856188e27e7244d2589882a8cf44bb4e 100644 --- a/src/app/api/images/route.test.ts +++ b/src/app/api/images/route.test.ts @@ -2,7 +2,9 @@ import { PNG_BASE64, imageFormRequest, readSseEvents, + startImagesJsonUpstream, startResponsesImageUpstream, + startStreamingResponsesImageUpstream, startStreamingImageUpstream } from './route-test-helpers'; import { clearAppLogEntriesForTest } from '@/lib/app-logger'; @@ -33,7 +35,9 @@ beforeEach(() => { delete process.env.OPENAI_CHANNEL_1_API_KEYS; delete process.env.OPENAI_CHANNEL_1_BASE_URL; delete process.env.ENABLE_RESPONSES_IMAGE_BACKEND; + delete process.env.IMAGE_GENERATION_BACKEND; delete process.env.OPENAI_RESPONSES_API_MODEL; + delete process.env.IMAGE_STREAMING_STRATEGY; process.env.APP_LOG_LEVEL = 'warn'; process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'indexeddb'; clearAppLogEntriesForTest(); @@ -47,7 +51,7 @@ afterEach(async () => { console.error = originalConsoleError; }); -describe('POST /api/images streaming', () => { +describe('POST /api/images streaming', { concurrency: false }, () => { it('normalizes OtokAPI image stream events into the stable client SSE contract', async () => { const { POST } = await import('./route'); let upstreamBody = ''; @@ -129,6 +133,42 @@ describe('POST /api/images streaming', () => { } }); + it('normalizes JSON Images responses returned to stream requests into the stable SSE contract', async () => { + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startImagesJsonUpstream(async (body) => { + upstreamBody = body; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true, + imageStreamingStrategy: 'newapi-keepalive-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal(events[0].b64_json, PNG_BASE64); + assert.equal((events[1].images as Array>)[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + } finally { + await upstream.close(); + } + }); + it('returns an explicit SSE error when the upstream completed event has no image payload', async () => { const { POST } = await import('./route'); const upstream = await startStreamingImageUpstream(async () => [ @@ -245,6 +285,153 @@ describe('POST /api/images streaming', () => { } }); + it('rejects explicit image stream requests when the server strategy disables streaming', async () => { + process.env.IMAGE_STREAMING_STRATEGY = 'off'; + const { POST } = await import('./route'); + let upstreamCalls = 0; + const upstream = await startStreamingImageUpstream(async () => { + upstreamCalls += 1; + return []; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as Record; + assert.match(String(body.error), /流式兼容模式已关闭/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('lets request streaming strategy override a disabled server strategy', async () => { + process.env.IMAGE_STREAMING_STRATEGY = 'off'; + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream(async (body) => { + upstreamBody = body; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true, + imageStreamingStrategy: 'newapi-keepalive-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('uses force-sse to stream even when the request omits the legacy stream field', async () => { + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream(async (body) => { + upstreamBody = body; + return [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + imageStreamingStrategy: 'force-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('normalizes gaoren JSON-as-SSE completed image payloads into the stable client SSE contract', async () => { + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream(async (body) => { + upstreamBody = body; + return [ + { + event: 'image_generation.completed', + data: { + type: 'image_generation.completed', + data: [{ b64_json: PNG_BASE64 }] + } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true, + imageStreamingStrategy: 'newapi-keepalive-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal(events[0].b64_json, PNG_BASE64); + assert.equal((events[1].images as Array>)[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.partial_images, 2); + } finally { + await upstream.close(); + } + }); + it('rejects the experimental Responses API backend when the feature flag is disabled', async () => { const { POST } = await import('./route'); const response = await POST( @@ -295,17 +482,6 @@ describe('POST /api/images streaming', () => { assert.equal(multiImage.status, 400); assert.match(String(((await multiImage.json()) as Record).error), /单张生成/); - const streaming = await POST( - imageFormRequest({ - apiBaseUrl: 'http://127.0.0.1:1/v1', - apiKey: 'test-key', - stream: true, - imageBackend: 'responses' - }) - ); - assert.equal(streaming.status, 400); - assert.match(String(((await streaming.json()) as Record).error), /不接入.*流式/); - const edit = await POST( imageFormRequest({ apiBaseUrl: 'http://127.0.0.1:1/v1', @@ -344,7 +520,7 @@ describe('POST /api/images streaming', () => { apiBaseUrl: upstream.baseUrl, apiKey: 'test-key', stream: false, - imageBackend: 'responses' + imageBackend: 'responses-image-generation' }) ); @@ -366,6 +542,501 @@ describe('POST /api/images streaming', () => { } }); + it('returns a 502 JSON error when non-streaming Responses image_generation fails upstream', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + const upstream = await startResponsesImageUpstream(async () => ({ + output: [ + { + type: 'image_generation_call', + status: 'failed', + error: { + code: 'content_policy_violation', + message: 'blocked by upstream policy' + } + } + ] + })); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false, + imageBackend: 'responses-image-generation' + }) + ); + + assert.equal(response.status, 502); + const body = (await response.json()) as Record; + assert.match(String(body.error), /blocked by upstream policy/); + } finally { + await upstream.close(); + } + }); + + it('fails non-streaming Images API JSON results that only contain a remote URL', async () => { + const { POST } = await import('./route'); + const upstream = await startImagesJsonUpstream(async () => ({ + data: [{ url: 'https://example.test/final.png' }] + })); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false + }) + ); + + assert.equal(response.status, 502); + const body = (await response.json()) as Record; + assert.match(String(body.error), /base64/); + assert.equal(JSON.stringify(body).includes('https://example.test/final.png'), false); + } finally { + await upstream.close(); + } + }); + + it('streams Responses API image_generation events through the stable page SSE contract', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startStreamingResponsesImageUpstream(async (body) => { + upstreamBody = body; + return [ + { + event: 'response.image_generation_call.partial_image', + data: { + type: 'response.image_generation_call.partial_image', + partial_image_b64: 'responses-partial-base64', + partial_image_index: 0 + } + }, + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true, + imageBackend: 'responses', + imageStreamingStrategy: 'responses-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['partial_image', 'completed', 'done'] + ); + assert.equal(events[0].b64_json, 'responses-partial-base64'); + assert.equal(events[1].b64_json, PNG_BASE64); + assert.equal((events[2].images as Array>)[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-4.1'); + assert.equal(upstreamJson.stream, true); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].type, 'image_generation'); + assert.equal(tools[0].partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('keeps separate Responses final items when their base64 payloads match', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + const upstream = await startStreamingResponsesImageUpstream(async () => [ + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + id: 'ig_same_payload_a', + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + }, + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + id: 'ig_same_payload_b', + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + } + ]); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true, + imageBackend: 'responses', + imageStreamingStrategy: 'responses-sse' + }) + ); + + assert.equal(response.status, 200); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'completed', 'done'] + ); + assert.equal((events[2].images as Array>).length, 2); + } finally { + await upstream.close(); + } + }); + + it('uses force-sse for Responses image_generation without the legacy stream field', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startStreamingResponsesImageUpstream(async (body) => { + upstreamBody = body; + return [ + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false, + imageBackend: 'responses-image-generation', + imageStreamingStrategy: 'force-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal(events[0].b64_json, PNG_BASE64); + + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.stream, true); + assert.equal(upstreamJson.model, 'gpt-4.1'); + const tools = upstreamJson.tools as Array>; + assert.equal(tools[0].type, 'image_generation'); + assert.equal(tools[0].partial_images, 2); + } finally { + await upstream.close(); + } + }); + + it('turns failed Responses image_generation calls into the stable page SSE error contract', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + const upstream = await startStreamingResponsesImageUpstream(async () => [ + { + event: 'response.output_item.done', + data: { + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'failed', + error: { + code: 'content_policy_violation', + message: 'blocked by upstream policy' + } + } + } + } + ]); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true, + imageBackend: 'responses', + imageStreamingStrategy: 'responses-sse' + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['error'] + ); + assert.equal(events[0].status, 502); + assert.match(String(events[0].error), /blocked by upstream policy/); + } finally { + await upstream.close(); + } + }); + + it('uses IMAGE_GENERATION_BACKEND as the route default when the request omits imageBackend', async () => { + process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; + process.env.IMAGE_GENERATION_BACKEND = 'responses'; + process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1'; + const { POST } = await import('./route'); + let upstreamBody = ''; + const upstream = await startResponsesImageUpstream(async (body) => { + upstreamBody = body; + return { + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: PNG_BASE64 + } + ] + }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { images?: Array> }; + assert.equal(body.images?.[0]?.b64_json, PNG_BASE64); + const upstreamJson = JSON.parse(upstreamBody) as Record; + assert.equal(upstreamJson.model, 'gpt-4.1'); + assert.equal(upstreamJson.stream, false); + } finally { + await upstream.close(); + } + }); + + it('does not apply the generate backend env default to edit requests', async () => { + process.env.IMAGE_GENERATION_BACKEND = 'responses'; + const { POST } = await import('./route'); + let upstreamUrl = ''; + const upstream = await startImagesJsonUpstream(async (_body, url) => { + upstreamUrl = url; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + mode: 'edit', + stream: false + }) + ); + + assert.equal(response.status, 200); + const body = (await response.json()) as { images?: Array> }; + assert.equal(body.images?.[0]?.b64_json, PNG_BASE64); + assert.equal(upstreamUrl, '/v1/images/edits'); + } finally { + await upstream.close(); + } + }); + + it('does not apply Responses-only streaming strategy env defaults to edit streams', async () => { + process.env.IMAGE_GENERATION_BACKEND = 'responses'; + process.env.IMAGE_STREAMING_STRATEGY = 'responses-sse'; + const { POST } = await import('./route'); + let upstreamUrl = ''; + let upstreamBody = ''; + const upstream = await startStreamingImageUpstream(async (body, url) => { + upstreamUrl = url; + upstreamBody = body; + return [ + { + event: 'image_edit.completed', + data: { type: 'image_edit.completed', b64_json: PNG_BASE64 } + } + ]; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + mode: 'edit', + stream: true + }) + ); + + assert.equal(response.status, 200); + assert.equal(response.headers.get('content-type'), 'text/event-stream'); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal(upstreamUrl, '/v1/images/edits'); + assert.match(upstreamBody, /name="stream"/); + assert.match(upstreamBody, /name="partial_images"/); + } finally { + await upstream.close(); + } + }); + + it('reports invalid image upstream env configuration as a server error before contacting upstream', async () => { + process.env.IMAGE_STREAMING_STRATEGY = 'not-a-strategy'; + const { POST } = await import('./route'); + let upstreamCalls = 0; + const upstream = await startStreamingImageUpstream(async () => { + upstreamCalls += 1; + return []; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: false + }) + ); + + assert.equal(response.status, 500); + const body = (await response.json()) as Record; + assert.match(String(body.error), /IMAGE_STREAMING_STRATEGY/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('treats blank APP_PASSWORD as disabled for page SSE auth', async () => { + process.env.APP_PASSWORD = ' '; + const { POST } = await import('./route'); + const upstream = await startStreamingImageUpstream(async () => [ + { + event: 'image_generation.completed', + data: { type: 'image_generation.completed', b64_json: PNG_BASE64 } + } + ]); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + stream: true + }) + ); + + assert.equal(response.status, 200); + const events = await readSseEvents(response); + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + } finally { + await upstream.close(); + } + }); + + it('rejects overlong page SSE client request ids before contacting upstream', async () => { + const { POST } = await import('./route'); + let upstreamCalls = 0; + const upstream = await startImagesJsonUpstream(async () => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + clientRequestId: 'x'.repeat(129) + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as Record; + assert.match(String(body.error), /clientRequestId/); + assert.match(String(body.error), /128/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + + it('rejects invalid gpt-image-2 custom sizes before contacting upstream', async () => { + const { POST } = await import('./route'); + let upstreamCalls = 0; + const upstream = await startImagesJsonUpstream(async () => { + upstreamCalls += 1; + return { data: [{ b64_json: PNG_BASE64 }] }; + }); + + try { + const response = await POST( + imageFormRequest({ + apiBaseUrl: upstream.baseUrl, + apiKey: 'test-key', + size: '2049x2048' + }) + ); + + assert.equal(response.status, 400); + const body = (await response.json()) as Record; + assert.match(String(body.error), /size 对 gpt-image-2 无效/); + assert.match(String(body.error), /16 的倍数/); + assert.equal(upstreamCalls, 0); + } finally { + await upstream.close(); + } + }); + it('lets request responsesModel override the experimental backend env model', async () => { process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true'; process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1-env'; diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts index 533565a94a91a8729ccdca02f68c87cdef341514..79f2c87682bb79cba4c34bc61f235dd1d588c655 100644 --- a/src/app/api/images/route.ts +++ b/src/app/api/images/route.ts @@ -10,12 +10,6 @@ import { readStorageMode, validateApiBaseUrl } from '@/lib/image-request-utils'; -import { - InvalidOpenAiImagesResponseError, - MissingOpenAiImageDataError, - persistedImageToLegacyResponse, - persistOpenAiImages -} from '@/lib/image-service'; import { handleEditImageMode, handleGenerateImageMode } from '@/lib/image-route-mode-handlers'; import { assertResponsesImageBackendAllowed, @@ -23,12 +17,22 @@ import { describeInvalidImagesResponse, ensureOutputDirExists, readClientRequestId, - readImageBackend, reportServerCredentialFailure, resolveRequestActualCostSafely, type AccessCookie, type RequestLogContext } from '@/lib/image-route-support'; +import { + InvalidOpenAiImagesResponseError, + MissingOpenAiImageDataError, + persistedImageToLegacyResponse, + persistOpenAiImages +} from '@/lib/image-service'; +import { + readImageGenerationBackend, + readImageStreamingStrategy, + resolveImageStreamEnabled +} from '@/lib/image-upstream-strategy'; import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; import { getServerChannelState } from '@/lib/server-channel-router'; import { buildAccessCookie, readAffinityKey, verifyPasswordHash } from '@/lib/server-runtime'; @@ -97,7 +101,7 @@ export async function POST(request: NextRequest) { await ensureOutputDirExists(); } - const appPassword = process.env.APP_PASSWORD; + const appPassword = process.env.APP_PASSWORD?.trim(); if (appPassword) { const clientPasswordHash = formData.get('passwordHash'); if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) { @@ -128,10 +132,25 @@ export async function POST(request: NextRequest) { requestLogContext ); - const streamEnabled = formData.get('stream') === 'true'; + const requestedStream = formData.get('stream') === 'true'; const partialImagesCount = readCount(formData, 'partial_images', 2, 1, 3) as 1 | 2 | 3; - const imageBackend = readImageBackend(formData); - assertResponsesImageBackendAllowed({ imageBackend, mode, streamEnabled }); + const imageBackend = readImageGenerationBackend(formData, process.env, { useEnvDefault: mode === 'generate' }); + const streamingStrategy = readImageStreamingStrategy(formData, process.env, { + useEnvDefault: mode === 'generate' + }); + const streamEnabled = resolveImageStreamEnabled({ + imageBackend, + requestedStream, + streamingStrategy + }); + assertResponsesImageBackendAllowed({ imageBackend, mode }); + appLogger.info('图片上游兼容策略。', { + ...requestLogContext, + imageBackend, + streamingStrategy, + requestedStream, + streamEnabled + }); const modeResult = mode === 'generate' @@ -150,7 +169,8 @@ export async function POST(request: NextRequest) { clientRequestId, requestLogContext, selectedCredential, - accessCookie + accessCookie, + abortSignal: request.signal }) : await handleEditImageMode({ formData, @@ -166,7 +186,8 @@ export async function POST(request: NextRequest) { clientRequestId, requestLogContext, selectedCredential, - accessCookie + accessCookie, + abortSignal: request.signal }); if (modeResult instanceof Response) { return modeResult; diff --git a/src/app/api/logs/route.test.ts b/src/app/api/logs/route.test.ts index 909be428f31760185fe36c0e8481eb7583059993..5f034d10c7ebca8e6b055784fdf8cd6ab285276f 100644 --- a/src/app/api/logs/route.test.ts +++ b/src/app/api/logs/route.test.ts @@ -1,8 +1,8 @@ import { GET } from './route'; -import { createHash } from 'node:crypto'; +import { NextRequest } from 'next/server'; import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; import { afterEach, describe, it } from 'node:test'; -import { NextRequest } from 'next/server'; const originalAppPassword = process.env.APP_PASSWORD; const originalAppLogLevel = process.env.APP_LOG_LEVEL; diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts index 2397708a35937de311c3de483e0df7ba49c6f1fa..501748f414710fbf5fbce85383b78ddc0eff8a51 100644 --- a/src/app/api/logs/route.ts +++ b/src/app/api/logs/route.ts @@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from 'next/server'; export const dynamic = 'force-dynamic'; function isAuthorized(request: NextRequest): boolean { - const appPassword = process.env.APP_PASSWORD; + const appPassword = process.env.APP_PASSWORD?.trim(); if (!appPassword) return false; const authorization = request.headers.get('authorization') || ''; @@ -23,7 +23,7 @@ function encodeSseComment(encoder: TextEncoder, value: string): Uint8Array { } export async function GET(request: NextRequest) { - if (!process.env.APP_PASSWORD) { + if (!process.env.APP_PASSWORD?.trim()) { return NextResponse.json({ error: '日志查看需要先配置 APP_PASSWORD。' }, { status: 403 }); } diff --git a/src/app/api/shares/route.test.ts b/src/app/api/shares/route.test.ts index 47306313451ca7f6d989d7331d5e843e7b31dab0..4b579b212fb21b5283791ba3a34f473c35b3f695 100644 --- a/src/app/api/shares/route.test.ts +++ b/src/app/api/shares/route.test.ts @@ -1,16 +1,16 @@ -import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; +import { POST as getShareContent } from './[token]/content/route'; +import { GET as getShare } from './[token]/route'; +import { POST } from './route'; import { resetAgentStateStoreForTests } from '@/lib/agent-state-runtime'; +import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; import { createAccessToken } from '@/lib/server-runtime'; import { createImageShare } from '@/lib/share-store'; +import { NextRequest } from 'next/server'; import assert from 'node:assert/strict'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { afterEach, describe, it } from 'node:test'; -import { NextRequest } from 'next/server'; -import { POST as getShareContent } from './[token]/content/route'; -import { GET as getShare } from './[token]/route'; -import { POST } from './route'; const originalAppPassword = process.env.APP_PASSWORD; const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-'); @@ -42,7 +42,8 @@ afterEach(async () => { function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) { const headers = new Headers(); - const accessToken = options.accessToken === undefined ? createAccessToken(PAGE_PASSWORD_FIXTURE) : options.accessToken; + const accessToken = + options.accessToken === undefined ? createAccessToken(PAGE_PASSWORD_FIXTURE) : options.accessToken; if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`); return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form }); } @@ -110,6 +111,17 @@ describe('POST /api/shares', { concurrency: false }, () => { assert.equal(response.status, 201); }); + it('allows share creation when APP_PASSWORD is blank', async () => { + await withTempCwd(); + process.env.APP_PASSWORD = ' '; + const form = new FormData(); + form.set('sourceFilename', 'result.png'); + form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' })); + + const response = await POST(createShareRequest(form, { accessToken: null })); + assert.equal(response.status, 201); + }); + it('treats blank access codes as public shares', async () => { await withTempCwd(); process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE; @@ -245,7 +257,10 @@ describe('share metadata and content routes', { concurrency: false }, () => { expiresInMinutes: 60 }); - const response = await getShare(new Request(`http://localhost/api/shares/${record.token}`), params(record.token)); + const response = await getShare( + new Request(`http://localhost/api/shares/${record.token}`), + params(record.token) + ); assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.token, record.token); @@ -267,7 +282,10 @@ describe('share metadata and content routes', { concurrency: false }, () => { now: new Date(Date.now() - 120_000) }); - const response = await getShare(new Request(`http://localhost/api/shares/${record.token}`), params(record.token)); + const response = await getShare( + new Request(`http://localhost/api/shares/${record.token}`), + params(record.token) + ); assert.equal(response.status, 200); const body = await response.json(); assert.equal(body.expired, true); @@ -284,7 +302,10 @@ describe('share metadata and content routes', { concurrency: false }, () => { }); const missing = await getShareContent( - new Request(`http://localhost/api/shares/${record.token}/content`, { method: 'POST', body: JSON.stringify({}) }), + new Request(`http://localhost/api/shares/${record.token}/content`, { + method: 'POST', + body: JSON.stringify({}) + }), params(record.token) ); assert.equal(missing.status, 401); diff --git a/src/app/api/shares/route.ts b/src/app/api/shares/route.ts index 4dd22bc7782f825017b77481800a122a6dd3e6f7..4842d8410ec9101e127e3a31e107dbda6b4f43e5 100644 --- a/src/app/api/shares/route.ts +++ b/src/app/api/shares/route.ts @@ -1,6 +1,6 @@ import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth'; -import { createImageShare } from '@/lib/share-store'; import { verifyAccessToken } from '@/lib/server-runtime'; +import { createImageShare } from '@/lib/share-store'; import { NextRequest, NextResponse } from 'next/server'; const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024; @@ -21,7 +21,11 @@ function detectImageMimeType(buffer: Buffer): string | undefined { if (buffer.length >= 4 && buffer[0] === 0xff && buffer[1] === 0xd8) { return 'image/jpeg'; } - if (buffer.length >= 30 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') { + if ( + buffer.length >= 30 && + buffer.toString('ascii', 0, 4) === 'RIFF' && + buffer.toString('ascii', 8, 12) === 'WEBP' + ) { return 'image/webp'; } return undefined; @@ -72,9 +76,10 @@ function parseSourceFilename(value: FormDataEntryValue | null, fallback: string) } function verifyShareCreator(request: NextRequest) { - if (!process.env.APP_PASSWORD) return undefined; + const appPassword = process.env.APP_PASSWORD?.trim(); + if (!appPassword) return undefined; const accessToken = request.cookies.get('gptImageAccess')?.value; - if (verifyAccessToken(accessToken, process.env.APP_PASSWORD)) return undefined; + if (verifyAccessToken(accessToken, appPassword)) return undefined; const code = accessToken ? PAGE_PASSWORD_AUTH_ERROR_CODES.invalid : PAGE_PASSWORD_AUTH_ERROR_CODES.missing; return jsonError(code, '未授权:无效的访问令牌。', 401); } diff --git a/src/app/page.tsx b/src/app/page.tsx index cac58b10556901c7c8ae6602cff1cc0188c7ea98..58b860a86048864e68b7fc0442b2309b9db7b8f8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -19,6 +19,10 @@ import { import { calculateApiCost, type CostDetails, type GptImageModel } from '@/lib/cost-utils'; import { db, type ImageRecord } from '@/lib/db'; import { useI18n } from '@/lib/i18n'; +import { + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, + appendImageUpstreamOverrideFields +} from '@/lib/image-upstream-form'; import { hasPreservedDisplayedAuthError, isPagePasswordAuthErrorCode } from '@/lib/page-password-auth'; import { createImageShareFromBlob } from '@/lib/share-client'; import { sha256Hex } from '@/lib/sha256'; @@ -361,12 +365,18 @@ export default function HomePage() { const [genCompression, setGenCompression] = React.useState([100]); const [genBackground, setGenBackground] = React.useState('auto'); const [genModeration, setGenModeration] = React.useState('auto'); + const [genImageBackend, setGenImageBackend] = + React.useState(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + const [genStreamingStrategy, setGenStreamingStrategy] = + React.useState(IMAGE_UPSTREAM_FORM_SERVER_DEFAULT); + const [genResponsesModel, setGenResponsesModel] = React.useState(''); const [editModel, setEditModel] = React.useState('gpt-image-2'); // 流式状态,由生成和编辑模式共用。 - const [enableStreaming, setEnableStreaming] = React.useState(true); + const [enableStreaming, setEnableStreaming] = React.useState(false); const [partialImages, setPartialImages] = React.useState<1 | 2 | 3>(1); + const [activeRequestStreaming, setActiveRequestStreaming] = React.useState(false); // 流式预览图,存储流式过程中的局部图片 base64 data URL。 const [streamingPreviewImages, setStreamingPreviewImages] = React.useState>(new Map()); const streamingBatchCapacity = resolveStreamingBatchCapacity({ @@ -884,6 +894,11 @@ export default function HomePage() { } apiFormData.append('background', genData.background); apiFormData.append('moderation', genData.moderation); + appendImageUpstreamOverrideFields(apiFormData, { + imageBackend: genData.image_backend, + streamingStrategy: genData.streaming_strategy, + responsesModel: genData.responsesModel + }); } else { const editData = formData as EditingFormData; apiFormData.append('model', editData.model); @@ -1058,6 +1073,7 @@ export default function HomePage() { let durationMs = 0; setIsLoading(true); + setActiveRequestStreaming(requestStreaming); setError(null); setLatestImageBatch(null); setImageOutputView('grid'); @@ -1172,6 +1188,7 @@ export default function HomePage() { await refreshRuntimeCapabilities(); } finally { if (durationMs === 0) durationMs = Date.now() - startTime; + setActiveRequestStreaming(false); setIsLoading(false); } } @@ -1191,7 +1208,10 @@ export default function HomePage() { : {}), background: genBackground, moderation: genModeration, - model: genModel + model: genModel, + image_backend: genImageBackend, + streaming_strategy: genStreamingStrategy, + responsesModel: genResponsesModel }); return; } @@ -1607,6 +1627,12 @@ export default function HomePage() { allowStreamingBatch={streamingBatchEnabled} partialImages={partialImages} setPartialImages={setPartialImages} + imageBackend={genImageBackend} + setImageBackend={setGenImageBackend} + streamingStrategy={genStreamingStrategy} + setStreamingStrategy={setGenStreamingStrategy} + responsesModel={genResponsesModel} + setResponsesModel={setGenResponsesModel} />
@@ -1682,6 +1708,7 @@ export default function HomePage() { currentMode={mode} baseImagePreviewUrl={editSourceImagePreviewUrls[0] || null} streamingPreviewImages={streamingPreviewImages} + isStreamingRequest={activeRequestStreaming} clientPasswordHash={clientPasswordHash} canOpenLogs={canOpenLogs} openLogsSignal={openLogsSignal} diff --git a/src/components/generation-form.tsx b/src/components/generation-form.tsx index 12641943a763b8585eeb6ed9569b2afb3a749ab7..a97b43d5670e4382a08136610b0abc9b87c6e0c9 100644 --- a/src/components/generation-form.tsx +++ b/src/components/generation-form.tsx @@ -13,8 +13,13 @@ import { Textarea } from '@/components/ui/textarea'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import type { GptImageModel } from '@/lib/cost-utils'; import { useI18n } from '@/lib/i18n'; -import { getPresetTooltip, validateGptImage2Size } from '@/lib/size-utils'; +import type { ImageUpstreamFormBackend, ImageUpstreamFormStreamingStrategy } from '@/lib/image-upstream-form'; +import { getPresetDimensions, getPresetTooltip, validateGptImage2Size } from '@/lib/size-utils'; import type { SizePreset } from '@/lib/size-utils'; +import { + shouldRecommendImageStreaming, + type ImageStreamingStrategy +} from '@/lib/image-upstream-strategy'; import { Square, RectangleHorizontal, @@ -50,6 +55,9 @@ export type GenerationFormData = { background: 'transparent' | 'opaque' | 'auto'; moderation: 'low' | 'auto'; model: GptImageModel; + image_backend: ImageUpstreamFormBackend; + streaming_strategy: ImageUpstreamFormStreamingStrategy; + responsesModel: string; }; type GenerationFormProps = { @@ -87,6 +95,12 @@ type GenerationFormProps = { allowStreamingBatch: boolean; partialImages: 1 | 2 | 3; setPartialImages: React.Dispatch>; + imageBackend: GenerationFormData['image_backend']; + setImageBackend: React.Dispatch>; + streamingStrategy: GenerationFormData['streaming_strategy']; + setStreamingStrategy: React.Dispatch>; + responsesModel: string; + setResponsesModel: React.Dispatch>; }; const RadioItemWithIcon = ({ @@ -126,6 +140,21 @@ const RadioItemWithIcon = ({ ); }; +function readConcreteSize(input: { + size: SizePreset; + model: GptImageModel; + customWidth: number; + customHeight: number; +}): { width: number; height: number } | null { + if (input.size === 'custom') { + return { width: input.customWidth, height: input.customHeight }; + } + const preset = getPresetDimensions(input.size, input.model); + if (!preset) return null; + const [width, height] = preset.split('x').map(Number); + return Number.isFinite(width) && Number.isFinite(height) ? { width, height } : null; +} + export function GenerationForm({ onSubmit, isLoading, @@ -160,7 +189,13 @@ export function GenerationForm({ setEnableStreaming, allowStreamingBatch, partialImages, - setPartialImages + setPartialImages, + imageBackend, + setImageBackend, + streamingStrategy, + setStreamingStrategy, + responsesModel, + setResponsesModel }: GenerationFormProps) { const { locale, t } = useI18n(); const showCompression = outputFormat === 'jpeg' || outputFormat === 'webp'; @@ -180,6 +215,22 @@ export function GenerationForm({ : t(customSizeValidation.reasonKey, customSizeValidation.values); const streamingDisabledByCount = n[0] > 1 && !allowStreamingBatch; + const effectiveStreamingStrategy: ImageStreamingStrategy = + streamingStrategy === 'server-default' ? 'auto' : streamingStrategy; + const streamingDisabledByStrategy = effectiveStreamingStrategy === 'off'; + const concreteSize = readConcreteSize({ size, model, customWidth, customHeight }); + const recommendStreaming = + !streamingDisabledByCount && + Boolean( + concreteSize && + shouldRecommendImageStreaming({ + streamingStrategy: effectiveStreamingStrategy, + quality, + width: concreteSize.width, + height: concreteSize.height, + streamEnabled: enableStreaming + }) + ); const [isAdvancedOpen, setIsAdvancedOpen] = React.useState(false); const submitDisabledReason = React.useMemo(() => { if (isLoading) return ''; @@ -190,10 +241,10 @@ export function GenerationForm({ // 未显式开启批量流式分发时,n > 1 会禁用流式输出。 React.useEffect(() => { - if (streamingDisabledByCount && enableStreaming) { + if ((streamingDisabledByCount || streamingDisabledByStrategy) && enableStreaming) { setEnableStreaming(false); } - }, [streamingDisabledByCount, enableStreaming, setEnableStreaming]); + }, [streamingDisabledByCount, streamingDisabledByStrategy, enableStreaming, setEnableStreaming]); // custom 仅对 gpt-image-2 有效,切换到旧模型时重置。 React.useEffect(() => { @@ -223,7 +274,10 @@ export function GenerationForm({ output_format: outputFormat, background, moderation, - model + model, + image_backend: imageBackend, + streaming_strategy: streamingStrategy, + responsesModel }; if (showCompression) { formData.output_compression = compression[0]; @@ -288,13 +342,13 @@ export function GenerationForm({ name='enable-streaming' checked={enableStreaming} onCheckedChange={(checked) => setEnableStreaming(!!checked)} - disabled={isLoading || streamingDisabledByCount} + disabled={isLoading || streamingDisabledByCount || streamingDisabledByStrategy} className='data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground' />
@@ -306,6 +360,11 @@ export function GenerationForm({ : t('streaming.description')} + {recommendStreaming && ( +

+ {t('streaming.highResolutionRecommendation')} +

+ )} @@ -462,6 +521,71 @@ export function GenerationForm({ {isAdvancedOpen && (
+
+
+ + +
+
+ + +
+
+ + {imageBackend === 'responses-image-generation' && ( +
+ + setResponsesModel(event.target.value)} + disabled={isLoading} + autoComplete='off' + spellCheck={false} + placeholder='gpt-5.4' + /> +
+ )} + {enableStreaming && (
diff --git a/src/components/image-output.tsx b/src/components/image-output.tsx index 8e82bde74500918a41a6ddc355ad8b961ca8213b..680a54f61f3cfc585b724ad56dc507d2d4996323 100644 --- a/src/components/image-output.tsx +++ b/src/components/image-output.tsx @@ -34,6 +34,7 @@ type ImageOutputProps = { currentMode: 'generate' | 'edit'; baseImagePreviewUrl: string | null; streamingPreviewImages?: Map; + isStreamingRequest?: boolean; clientPasswordHash: string | null; canOpenLogs: boolean; openLogsSignal?: number; @@ -76,6 +77,7 @@ export function ImageOutput({ currentMode, baseImagePreviewUrl, streamingPreviewImages, + isStreamingRequest = false, clientPasswordHash, canOpenLogs, openLogsSignal, @@ -264,7 +266,7 @@ export function ImageOutput({ ) : (
-

{t('output.generating')}

+

{isStreamingRequest ? t('output.keepalive') : t('output.generating')}

) ) : imageBatch && imageBatch.length > 0 ? ( diff --git a/src/lib/agent-api-contracts.test.ts b/src/lib/agent-api-contracts.test.ts index b0322d8cfd256f04932082a593440748caed1ff7..38bd0db5a619413ea87551ad195b7113071b3ae0 100644 --- a/src/lib/agent-api-contracts.test.ts +++ b/src/lib/agent-api-contracts.test.ts @@ -1,4 +1,6 @@ import { + AGENT_SCHEMA_VERSION, + PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH, buildAgentAuthCapabilities, buildAgentCapabilities, readAgentLeaseMs, @@ -7,6 +9,7 @@ import { readAgentRequestTtlSeconds, validateAgentGenerateRequest } from './agent-api-contracts'; +import { AGENT_ENDPOINTS, AGENT_JOB_ENDPOINTS } from './agent-api-paths.mjs'; import { buildAgentOpenApiDocument } from './agent-openapi'; import { RequestValidationError } from './image-request-utils'; import assert from 'node:assert/strict'; @@ -23,10 +26,57 @@ describe('validateAgentGenerateRequest', () => { output_format: 'png', background: 'auto', moderation: 'auto', - response_mode: 'path' + response_mode: 'path', + image_backend: 'images-api', + streaming_strategy: 'off', + partial_images: 2 }); }); + it('accepts explicit Agent upstream streaming strategy fields', () => { + assert.deepEqual( + validateAgentGenerateRequest({ + prompt: 'draw a stable streaming image', + image_backend: 'images', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 3 + }), + { + model: 'gpt-image-2', + prompt: 'draw a stable streaming image', + n: 1, + size: '1024x1024', + quality: 'high', + output_format: 'png', + background: 'auto', + moderation: 'auto', + response_mode: 'path', + image_backend: 'images-api', + streaming_strategy: 'newapi-keepalive-sse', + partial_images: 3 + } + ); + }); + + it('rejects non-string Agent upstream strategy fields instead of defaulting them', () => { + assert.throws( + () => + validateAgentGenerateRequest({ + prompt: 'bad upstream strategy fields', + image_backend: 123, + streaming_strategy: true + }), + (error) => { + assert.ok(error instanceof RequestValidationError); + assert.equal(error.status, 422); + const details = JSON.parse(error.message) as { fields: Record }; + assert.match(details.fields.image_backend, /字符串/); + assert.match(details.fields.streaming_strategy, /字符串/); + return true; + } + ); + }); + it('returns field-level validation errors for agent-correctable inputs', () => { assert.throws( () => @@ -49,6 +99,24 @@ describe('validateAgentGenerateRequest', () => { ); }); + it('rejects incompatible Agent image backend and streaming strategy combinations', () => { + assert.throws( + () => + validateAgentGenerateRequest({ + prompt: 'bad strategy', + image_backend: 'images-api', + streaming_strategy: 'responses-sse' + }), + (error) => { + assert.ok(error instanceof RequestValidationError); + assert.equal(error.status, 422); + const details = JSON.parse(error.message) as { fields: Record }; + assert.match(details.fields.streaming_strategy, /Images API/); + return true; + } + ); + }); + it('rejects transparent background for gpt-image-2', () => { assert.throws( () => @@ -88,19 +156,40 @@ describe('Agent numeric configuration', () => { }); it('fails explicitly when numeric env values are invalid', () => { - assert.throws(() => readAgentRequestTtlSeconds({ AGENT_REQUEST_TTL_SECONDS: 'abc' }), /AGENT_REQUEST_TTL_SECONDS/); + assert.throws( + () => readAgentRequestTtlSeconds({ AGENT_REQUEST_TTL_SECONDS: 'abc' }), + /AGENT_REQUEST_TTL_SECONDS/ + ); assert.throws(() => readAgentLeaseMs({ AGENT_REQUEST_LEASE_MS: '0' }), /AGENT_REQUEST_LEASE_MS/); - assert.throws(() => readAgentRecoveryIntervalMs({ AGENT_RECOVERY_INTERVAL_MS: '-1' }), /AGENT_RECOVERY_INTERVAL_MS/); + assert.throws( + () => readAgentRecoveryIntervalMs({ AGENT_RECOVERY_INTERVAL_MS: '-1' }), + /AGENT_RECOVERY_INTERVAL_MS/ + ); }); it('validates the public OpenAPI server URL', () => { assert.equal(readAgentPublicBaseUrl({}), '/'); - assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test/' }), 'https://images.example.test'); - assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'http://localhost:4783' }), 'http://localhost:4783'); + assert.equal( + readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test/' }), + 'https://images.example.test' + ); + assert.equal( + readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'http://localhost:4783' }), + 'http://localhost:4783' + ); assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'not a url' }), /AGENT_PUBLIC_BASE_URL/); - assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'javascript:alert(1)' }), /AGENT_PUBLIC_BASE_URL/); - assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://user:pass@images.example.test' }), /AGENT_PUBLIC_BASE_URL/); - assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test?token=secret' }), /AGENT_PUBLIC_BASE_URL/); + assert.throws( + () => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'javascript:alert(1)' }), + /AGENT_PUBLIC_BASE_URL/ + ); + assert.throws( + () => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://user:pass@images.example.test' }), + /AGENT_PUBLIC_BASE_URL/ + ); + assert.throws( + () => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test?token=secret' }), + /AGENT_PUBLIC_BASE_URL/ + ); }); }); @@ -114,6 +203,10 @@ describe('buildAgentCapabilities', () => { }); assert.equal(capabilities.defaults.state_backend, 'postgres'); + assert.equal(capabilities.schema_version, AGENT_SCHEMA_VERSION); + assert.equal(capabilities.defaults.image_backend, 'images-api'); + assert.equal(capabilities.defaults.streaming_strategy, 'off'); + assert.equal(capabilities.defaults.partial_images, 2); assert.equal(capabilities.auth.required, true); assert.deepEqual(capabilities.auth.schemes, ['bearer']); assert.equal(capabilities.storage.postgres_configured, true); @@ -123,15 +216,109 @@ describe('buildAgentCapabilities', () => { assert.equal(capabilities.model_limits['gpt-image-2'].max_edge, 3840); assert.equal(capabilities.model_limits['gpt-image-2'].edge_multiple, 16); assert.equal(capabilities.model_limits['gpt-image-2'].max_pixels, 8294400); + assert.deepEqual(capabilities.model_limits['gpt-image-2'].large_image_risk.applies_to, [ + 'max_edge>2048', + 'long_running_upstream' + ]); + assert.match(capabilities.model_limits['gpt-image-2'].large_image_risk.guidance, /大尺寸/); assert.equal(capabilities.agent_streaming.generate.supported, false); assert.equal(capabilities.agent_streaming.generate.mode, 'non_streaming_only'); + assert.equal(capabilities.agent_streaming.upstream_sse.supported, true); + assert.equal(capabilities.agent_streaming.upstream_sse.mode, 'internal_upstream_sse'); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.request_fields, [ + 'image_backend', + 'streaming_strategy', + 'partial_images' + ]); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.image_backends, [ + 'images-api', + 'responses-image-generation' + ]); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.enabled_image_backends, ['images-api']); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.streaming_strategies, [ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' + ]); + assert.deepEqual(capabilities.agent_streaming.upstream_sse.activation_strategies, [ + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' + ]); + assert.equal(capabilities.agent_streaming.upstream_sse.final_response_contract, 'AgentImageResponse'); assert.equal(capabilities.agent_streaming.page_sse.endpoint, '/api/images'); - assert.equal(capabilities.endpoints.create_generate_job, '/api/agent/jobs/images/generate'); + assert.equal(capabilities.agent_streaming.page_sse.contract, 'page_ui_only'); + assert.equal(capabilities.agent_streaming.page_sse.transport_contract, 'page_form_data_sse'); + assert.deepEqual(capabilities.agent_streaming.page_sse.auth, { + required: false, + schemes: [], + form_field: 'passwordHash' + }); + assert.deepEqual(capabilities.agent_streaming.page_sse.client_request_id, { + form_field: 'clientRequestId', + source_header: 'Idempotency-Key', + max_length: PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH + }); + assert.equal( + capabilities.agent_streaming.page_sse.agent_usage, + 'recommended_for_high_resolution_generate_edit_and_complex_batch' + ); + assert.deepEqual(capabilities.routing_rules.high_resolution_edit, { + when: ['operation=edit', 'max_edge>2048'], + endpoint: '/api/images', + transport: 'page_sse', + strength: 'must_use', + reason: 'Agent edit is non-streaming and rejects high-resolution edit requests; use the page form-data SSE endpoint instead.' + }); + assert.equal(capabilities.routing_rules.complex_ui_batch.endpoint, '/api/images'); + assert.equal(capabilities.routing_rules.complex_ui_batch.strength, 'recommended'); + assert.equal(capabilities.routing_rules.long_image_recovery.endpoint, '/api/images'); + assert.equal(capabilities.routing_rules.long_image_recovery.transport, 'page_sse'); + assert.equal(capabilities.routing_rules.agent_generate_small_smoke.endpoint, AGENT_ENDPOINTS.generate); + assert.equal(capabilities.routing_rules.page_sse_large_generate.endpoint, '/api/images'); + assert.equal(capabilities.routing_rules.page_sse_large_generate.transport, 'page_sse'); + assert.equal(capabilities.routing_rules.page_sse_large_generate.strength, 'recommended'); + assert.equal(capabilities.routing_rules.retry_recovery.reuse_failed_idempotency_key, false); + assert.match(capabilities.routing_rules.retry_recovery.new_attempt_guidance, /new Idempotency-Key/); + assert.deepEqual(capabilities.supported.image_backends, ['images-api', 'responses-image-generation']); + assert.deepEqual(capabilities.supported.enabled_image_backends, ['images-api']); + assert.deepEqual(capabilities.supported.image_backend_requirements['images-api'], { + supported: true, + enabled: true, + required_env: [], + missing_env: [] + }); + assert.deepEqual(capabilities.supported.image_backend_requirements['responses-image-generation'], { + supported: true, + enabled: false, + required_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'], + missing_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'] + }); + assert.deepEqual(capabilities.supported.streaming_strategies, [ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' + ]); + assert.equal(capabilities.endpoints.create_generate_job, AGENT_ENDPOINTS.create_generate_job); assert.equal(capabilities.agent_jobs.supported, true); assert.equal(capabilities.agent_jobs.mode, 'job_polling'); - assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, '/api/agent/jobs/images/generate'); + assert.deepEqual(capabilities.agent_jobs.intended_for, [ + 'explicit_job_route', + 'manual_after_diagnosis', + 'long_running_upstream_when_page_sse_not_selected' + ]); + assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, AGENT_JOB_ENDPOINTS.create_generate_job); assert.deepEqual(capabilities.agent_jobs.states, ['queued', 'running', 'succeeded', 'failed', 'expired']); - assert.match(capabilities.agent_jobs.current_guidance, /poll/i); + assert.match(capabilities.agent_jobs.current_guidance, /\/api\/images SSE/); + assert.match(capabilities.agent_jobs.current_guidance, /不自动回退/); + assert.match(capabilities.agent_jobs.current_guidance, /job/); }); it('exposes only the runtime-accepted bearer auth scheme when Agent token is configured', () => { @@ -144,6 +331,46 @@ describe('buildAgentCapabilities', () => { ); }); + it('marks Responses image backend runtime-enabled only when its feature flag and model are configured', () => { + const missingModel = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true' + }); + assert.deepEqual(missingModel.supported.enabled_image_backends, ['images-api']); + assert.deepEqual(missingModel.supported.image_backend_requirements['responses-image-generation'].missing_env, [ + 'OPENAI_RESPONSES_API_MODEL' + ]); + + const enabled = buildAgentCapabilities({ + ENABLE_RESPONSES_IMAGE_BACKEND: 'true', + OPENAI_RESPONSES_API_MODEL: 'gpt-4.1' + }); + assert.deepEqual(enabled.agent_streaming.upstream_sse.enabled_image_backends, [ + 'images-api', + 'responses-image-generation' + ]); + assert.deepEqual(enabled.supported.enabled_image_backends, ['images-api', 'responses-image-generation']); + assert.deepEqual(enabled.supported.image_backend_requirements['responses-image-generation'], { + supported: true, + enabled: true, + required_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'], + missing_env: [] + }); + }); + + it('exposes page SSE form password auth separately from Agent bearer auth', () => { + const capabilities = buildAgentCapabilities({ + AGENT_API_TOKEN: 'token', + APP_PASSWORD: 'page-access-code' + }); + + assert.deepEqual(capabilities.auth, { required: true, schemes: ['bearer'] }); + assert.deepEqual(capabilities.agent_streaming.page_sse.auth, { + required: true, + schemes: ['form-password-hash'], + form_field: 'passwordHash' + }); + }); + it('exposes access-code hash auth only when no Agent token is configured', () => { assert.deepEqual(buildAgentAuthCapabilities({ APP_PASSWORD: 'page-access-code' }), { required: true, @@ -175,11 +402,11 @@ describe('buildAgentCapabilities', () => { const document = buildAgentOpenApiDocument({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test' }); assert.equal(document.openapi, '3.1.0'); assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]); - assert.ok('/api/agent/openapi.json' in document.paths); - assert.ok('/api/agent/images/generate' in document.paths); - assert.ok('/api/agent/jobs/images/generate' in document.paths); - assert.ok('/api/agent/jobs/{id}' in document.paths); - assert.ok('/api/agent/jobs/{id}/result' in document.paths); + assert.ok(AGENT_ENDPOINTS.openapi in document.paths); + assert.ok(AGENT_ENDPOINTS.generate in document.paths); + assert.ok(AGENT_ENDPOINTS.create_generate_job in document.paths); + assert.ok(AGENT_ENDPOINTS.job in document.paths); + assert.ok(AGENT_ENDPOINTS.job_result in document.paths); assert.ok('AgentCapabilities' in document.components.schemas); assert.ok('AgentImageResponse' in document.components.schemas); assert.ok('AgentJobStatusResponse' in document.components.schemas); @@ -189,17 +416,89 @@ describe('buildAgentCapabilities', () => { assert.ok('AgentModelLimits' in document.components.schemas); assert.ok('AgentStreamingCapabilities' in document.components.schemas); assert.ok('AgentJobCapabilities' in document.components.schemas); + assert.ok('AgentRoutingRules' in document.components.schemas); + assert.ok('AgentRoutingRule' in document.components.schemas); assert.ok('AgentErrorDiagnostics' in document.components.schemas); - assert.ok(document.paths['/api/agent/images/generate'].post.responses['200']); - assert.ok(document.paths['/api/agent/images/generate'].post.responses['403']); - assert.ok(document.paths['/api/agent/images/generate'].post.responses['429']); - assert.ok(document.paths['/api/agent/images/generate'].post.responses['422']); - assert.ok(document.paths['/api/agent/jobs/images/generate'].post.responses['202']); - assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['200']); - assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['409']); - assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['422']); - assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['429']); - assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['502']); + assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['200']); + assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['403']); + assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['429']); + assert.ok(document.paths[AGENT_ENDPOINTS.generate].post.responses['422']); + assert.ok(document.paths[AGENT_ENDPOINTS.create_generate_job].post.responses['202']); + assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['200']); + assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['409']); + assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['422']); + assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['429']); + assert.ok(document.paths[AGENT_ENDPOINTS.job_result].get.responses['502']); + const generateProperties = document.components.schemas.GenerateRequest.properties; + assert.deepEqual(generateProperties.image_backend.enum, ['images-api', 'responses-image-generation']); + assert.deepEqual(generateProperties.streaming_strategy.enum, [ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' + ]); + assert.ok('partial_images' in generateProperties); + const capabilityProperties = document.components.schemas.AgentCapabilities.properties; + assert.equal(capabilityProperties.routing_rules.$ref, '#/components/schemas/AgentRoutingRules'); + assert.deepEqual(capabilityProperties.defaults.properties.image_backend.enum, [ + 'images-api', + 'responses-image-generation' + ]); + assert.deepEqual(capabilityProperties.supported.properties.image_backends.items.enum, [ + 'images-api', + 'responses-image-generation' + ]); + assert.deepEqual(capabilityProperties.supported.properties.enabled_image_backends.items.enum, [ + 'images-api', + 'responses-image-generation' + ]); + assert.ok(capabilityProperties.supported.properties.image_backend_requirements); + assert.deepEqual( + document.components.schemas.AgentStreamingCapabilities.properties.upstream_sse.properties + .enabled_image_backends.items.enum, + ['images-api', 'responses-image-generation'] + ); + assert.deepEqual( + document.components.schemas.AgentStreamingCapabilities.properties.upstream_sse.properties + .streaming_strategies.items.enum, + ['off', 'auto', 'openai-sse', 'newapi-keepalive-sse', 'responses-sse', 'force-sse'] + ); + assert.deepEqual( + document.components.schemas.AgentStreamingCapabilities.properties.upstream_sse.properties + .activation_strategies.items.enum, + ['openai-sse', 'newapi-keepalive-sse', 'responses-sse', 'force-sse'] + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.contract.enum[0], + 'page_ui_only' + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.transport_contract + .enum[0], + 'page_form_data_sse' + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.auth.properties + .form_field.const, + 'passwordHash' + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.client_request_id + .properties.max_length.const, + PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.client_request_id + .properties.source_header.const, + 'Idempotency-Key' + ); + assert.equal(document.components.schemas.AgentRoutingRules.required.includes('high_resolution_edit'), true); + assert.equal(document.components.schemas.AgentRoutingRules.required.includes('long_image_recovery'), true); + assert.match(document.components.schemas.EditRequest.description, /\/api\/images/); + assert.ok('upstream_event_type' in document.components.schemas.AgentErrorDiagnostics.properties); + assert.ok('partial_image_count' in document.components.schemas.AgentErrorDiagnostics.properties); }); it('describes public capabilities without server-local SQLite paths', () => { @@ -219,7 +518,19 @@ describe('buildAgentCapabilities', () => { assert.ok(document.components.securitySchemes.BearerAuth); assert.equal('AppPasswordHash' in document.components.securitySchemes, false); - assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, ['bearer']); + assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, [ + 'bearer' + ]); + assert.deepEqual( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.auth.properties + .schemes.const, + ['form-password-hash'] + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.auth.properties + .required.const, + true + ); assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ BearerAuth: [] }]); assert.ok(document.paths['/api/agent/images/generate'].post.responses['401']); assert.ok(document.paths['/api/agent/images/generate'].post.responses['415']); @@ -246,6 +557,16 @@ describe('buildAgentCapabilities', () => { assert.deepEqual(document.components.securitySchemes, {}); assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, []); + assert.deepEqual( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.auth.properties + .schemes.const, + [] + ); + assert.equal( + document.components.schemas.AgentStreamingCapabilities.properties.page_sse.properties.auth.properties + .required.const, + false + ); assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, []); }); diff --git a/src/lib/agent-api-contracts.ts b/src/lib/agent-api-contracts.ts index 476298e39b04a08d5491686595dcc66e4d7b136b..333780b4def942d735289cf07791be01339be11b 100644 --- a/src/lib/agent-api-contracts.ts +++ b/src/lib/agent-api-contracts.ts @@ -1,3 +1,5 @@ +import { AGENT_ENDPOINTS, AGENT_JOB_ENDPOINTS } from './agent-api-paths.mjs'; +import type { AgentErrorDiagnostics } from './api-error-response'; import { MAX_IMAGE_COUNT, MAX_PROMPT_LENGTH, @@ -7,6 +9,15 @@ import { type GptImageModel, type ValidOutputFormat } from './image-request-utils'; +import { + parseImageGenerationBackendValue, + parseImageStreamingStrategyValue, + resolveImageStreamEnabled, + type ImageGenerationBackend, + type ImageStreamingStrategy +} from './image-upstream-strategy'; +import { CHINESE_POSITIVE_INTEGER_MESSAGES, readPositiveIntegerFromEnv } from './positive-integer-config.mjs'; +import { readBooleanEnv } from './server-runtime'; import { GPT_IMAGE_2_EDGE_MULTIPLE, GPT_IMAGE_2_MAX_ASPECT, @@ -15,10 +26,9 @@ import { GPT_IMAGE_2_MIN_PIXELS, validateGptImage2Size } from './size-utils'; -import type { AgentErrorDiagnostics } from './api-error-response'; export const AGENT_API_VERSION = '1.0.0'; -export const AGENT_SCHEMA_VERSION = '2026-05-20'; +export const AGENT_SCHEMA_VERSION = '2026-05-24'; export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite'; export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000; export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60; @@ -32,14 +42,39 @@ export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const; export const AGENT_MODERATIONS = ['low', 'auto'] as const; export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const; export const AGENT_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'expired'] as const; +export const AGENT_IMAGE_BACKENDS = ['images-api', 'responses-image-generation'] as const; +export const PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH = 128; +export const AGENT_STREAMING_STRATEGIES = [ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' +] as const; +export const AGENT_UPSTREAM_SSE_ACTIVATION_STRATEGIES = [ + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' +] as const; export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres'; export type AgentAuthScheme = 'bearer' | 'x-app-password-hash'; +export type PageSseAuthScheme = 'form-password-hash'; export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number]; export type AgentQuality = (typeof AGENT_QUALITIES)[number]; export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number]; export type AgentModeration = (typeof AGENT_MODERATIONS)[number]; export type AgentJobState = (typeof AGENT_JOB_STATES)[number]; +export type AgentRoutingTransport = 'agent_json' | 'agent_job_polling' | 'page_sse'; +export type AgentRoutingStrength = 'default' | 'recommended' | 'must_use'; +export type ImageBackendRuntimeRequirement = { + supported: true; + enabled: boolean; + required_env: string[]; + missing_env: string[]; +}; export type AgentGenerateRequest = { model: GptImageModel; @@ -52,6 +87,9 @@ export type AgentGenerateRequest = { background: AgentBackground; moderation: AgentModeration; response_mode: AgentResponseMode; + image_backend: ImageGenerationBackend; + streaming_strategy: ImageStreamingStrategy; + partial_images: 1 | 2 | 3; }; export type AgentImageResponseItem = { @@ -111,6 +149,9 @@ export type AgentCapabilities = { model: GptImageModel; response_mode: AgentResponseMode; state_backend: AgentStateBackend; + image_backend: ImageGenerationBackend; + streaming_strategy: ImageStreamingStrategy; + partial_images: 1 | 2 | 3; }; limits: { max_prompt_length: number; @@ -126,7 +167,7 @@ export type AgentCapabilities = { max_aspect: number; min_pixels: number; recommended_presets: Array<{ name: string; size: string; purpose: string }>; - high_4k_risk: { + large_image_risk: { applies_to: string[]; guidance: string; }; @@ -143,11 +184,45 @@ export type AgentCapabilities = { mode: 'non_streaming_only'; endpoint: string; }; + upstream_sse: { + supported: true; + mode: 'internal_upstream_sse'; + endpoint: string; + request_fields: ['image_backend', 'streaming_strategy', 'partial_images']; + image_backends: readonly ImageGenerationBackend[]; + enabled_image_backends: readonly ImageGenerationBackend[]; + streaming_strategies: readonly ImageStreamingStrategy[]; + activation_strategies: readonly ImageStreamingStrategy[]; + final_response_contract: 'AgentImageResponse'; + }; page_sse: { supported: true; mode: 'form_data_sse'; endpoint: string; contract: 'page_ui_only'; + transport_contract: 'page_form_data_sse'; + auth: { + required: boolean; + schemes: PageSseAuthScheme[]; + form_field: 'passwordHash'; + }; + client_request_id: { + form_field: 'clientRequestId'; + source_header: 'Idempotency-Key'; + max_length: number; + }; + agent_usage: 'recommended_for_high_resolution_generate_edit_and_complex_batch'; + }; + }; + routing_rules: { + high_resolution_edit: AgentRoutingRule; + complex_ui_batch: AgentRoutingRule; + long_image_recovery: AgentRoutingRule; + agent_generate_small_smoke: AgentRoutingRule; + page_sse_large_generate: AgentRoutingRule; + retry_recovery: { + reuse_failed_idempotency_key: false; + new_attempt_guidance: string; }; }; agent_jobs: { @@ -170,6 +245,10 @@ export type AgentCapabilities = { backgrounds: readonly string[]; moderations: readonly string[]; legacy_sizes: readonly string[]; + image_backends: readonly ImageGenerationBackend[]; + enabled_image_backends: readonly ImageGenerationBackend[]; + image_backend_requirements: Record; + streaming_strategies: readonly ImageStreamingStrategy[]; }; storage: { image_storage_mode: string; @@ -182,6 +261,14 @@ export type AgentCapabilities = { }; }; +export type AgentRoutingRule = { + when: string[]; + endpoint: string; + transport: AgentRoutingTransport; + strength: AgentRoutingStrength; + reason: string; +}; + type FieldErrors = Record; function isOneOf(value: string, allowed: T): value is T[number] { @@ -204,7 +291,8 @@ function readIntegerField( ): number { const value = body[field]; if (value === undefined || value === null || value === '') return fallback; - const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; + const parsed = + typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; if (!Number.isInteger(parsed) || parsed < min || parsed > max) { fields[field] = `必须是 ${min} 到 ${max} 之间的整数`; return fallback; @@ -275,6 +363,59 @@ function readResponseMode(body: Record, fields: FieldErrors): A return value; } +function readAgentImageBackend(body: Record, fields: FieldErrors): ImageGenerationBackend { + const rawValue = body.image_backend; + if (rawValue !== undefined && rawValue !== null && typeof rawValue !== 'string') { + fields.image_backend = '必须是字符串'; + return 'images-api'; + } + const value = readStringField(body, 'image_backend', 'images-api'); + if (!value) return 'images-api'; + try { + return parseImageGenerationBackendValue(value); + } catch (error) { + fields.image_backend = error instanceof Error ? error.message : 'image_backend 无效'; + return 'images-api'; + } +} + +function readAgentStreamingStrategy(body: Record, fields: FieldErrors): ImageStreamingStrategy { + const rawValue = body.streaming_strategy; + if (rawValue !== undefined && rawValue !== null && typeof rawValue !== 'string') { + fields.streaming_strategy = '必须是字符串'; + return 'off'; + } + const value = readStringField(body, 'streaming_strategy', 'off'); + if (!value) return 'off'; + try { + return parseImageStreamingStrategyValue(value); + } catch (error) { + fields.streaming_strategy = error instanceof Error ? error.message : 'streaming_strategy 无效'; + return 'off'; + } +} + +function shouldAgentRequestUpstreamStream(streamingStrategy: ImageStreamingStrategy): boolean { + return streamingStrategy !== 'off' && streamingStrategy !== 'auto'; +} + +function validateAgentImageUpstreamStrategy(input: { + imageBackend: ImageGenerationBackend; + streamingStrategy: ImageStreamingStrategy; + fields: FieldErrors; +}) { + try { + resolveImageStreamEnabled({ + imageBackend: input.imageBackend, + requestedStream: shouldAgentRequestUpstreamStream(input.streamingStrategy), + streamingStrategy: input.streamingStrategy + }); + } catch (error) { + input.fields.streaming_strategy = + error instanceof Error ? error.message : 'streaming_strategy 与 image_backend 不兼容'; + } +} + function readQuality(body: Record, fields: FieldErrors): AgentQuality { const value = readStringField(body, 'quality', 'high'); if (!value || !isOneOf(value, AGENT_QUALITIES)) { @@ -316,7 +457,8 @@ function readOutputCompression( fields.output_compression = '仅适用于 jpeg 或 webp 输出'; return undefined; } - const parsed = typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; + const parsed = + typeof value === 'number' ? value : typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : NaN; if (!Number.isInteger(parsed) || parsed < 0 || parsed > 100) { fields.output_compression = '必须是 0 到 100 之间的整数'; return undefined; @@ -340,6 +482,10 @@ export function validateAgentGenerateRequest(body: unknown): AgentGenerateReques const background = readBackground(objectBody, model, fields); const moderation = readModeration(objectBody, fields); const responseMode = readResponseMode(objectBody, fields); + const imageBackend = readAgentImageBackend(objectBody, fields); + const streamingStrategy = readAgentStreamingStrategy(objectBody, fields); + const partialImages = readIntegerField(objectBody, 'partial_images', 2, 1, 3, fields) as 1 | 2 | 3; + validateAgentImageUpstreamStrategy({ imageBackend, streamingStrategy, fields }); if (Object.keys(fields).length > 0) { throw new RequestValidationError(JSON.stringify({ fields }), 422); @@ -355,7 +501,10 @@ export function validateAgentGenerateRequest(body: unknown): AgentGenerateReques ...(outputCompression !== undefined ? { output_compression: outputCompression } : {}), background, moderation, - response_mode: responseMode + response_mode: responseMode, + image_backend: imageBackend, + streaming_strategy: streamingStrategy, + partial_images: partialImages }; } @@ -414,40 +563,80 @@ export function buildAgentAuthCapabilities(env: Record, fieldName: string, fallback: number): number { - const value = env[fieldName]?.trim(); - if (!value) return fallback; - if (!/^\d+$/.test(value)) { - throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); +export function buildPageSseAuthCapabilities( + env: Record +): AgentCapabilities['agent_streaming']['page_sse']['auth'] { + return { + required: Boolean(env.APP_PASSWORD?.trim()), + schemes: env.APP_PASSWORD?.trim() ? ['form-password-hash'] : [], + form_field: 'passwordHash' + }; +} + +function readEnabledImageBackends( + requirements: Record +): ImageGenerationBackend[] { + return requirements['responses-image-generation'].enabled + ? ['images-api', 'responses-image-generation'] + : ['images-api']; +} + +function buildImageBackendRequirements( + env: Record +): Record { + const responsesMissingEnv = readResponsesImageBackendMissingEnv(env); + return { + 'images-api': { + supported: true, + enabled: true, + required_env: [], + missing_env: [] + }, + 'responses-image-generation': { + supported: true, + enabled: responsesMissingEnv.length === 0, + required_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'], + missing_env: responsesMissingEnv + } + }; +} + +function readResponsesImageBackendMissingEnv(env: Record): string[] { + const missing: string[] = []; + if (!readBooleanEnv(env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { + missing.push('ENABLE_RESPONSES_IMAGE_BACKEND'); } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500); + if (!env.OPENAI_RESPONSES_API_MODEL?.trim()) { + missing.push('OPENAI_RESPONSES_API_MODEL'); + } + return missing; +} + +function readPositiveIntegerEnv(env: Record, fieldName: string, fallback: number): number { + try { + return readPositiveIntegerFromEnv(env, fieldName, fallback, { + messages: CHINESE_POSITIVE_INTEGER_MESSAGES + }); + } catch (error) { + throw new RequestValidationError(error instanceof Error ? error.message : `${fieldName} 必须是正整数。`, 500); } - return parsed; } export function buildAgentCapabilities(env: Record): AgentCapabilities { + const imageBackendRequirements = buildImageBackendRequirements(env); + const enabledImageBackends = readEnabledImageBackends(imageBackendRequirements); return { api_version: AGENT_API_VERSION, schema_version: AGENT_SCHEMA_VERSION, auth: buildAgentAuthCapabilities(env), - endpoints: { - capabilities: '/api/agent/capabilities', - openapi: '/api/agent/openapi.json', - generate: '/api/agent/images/generate', - edit: '/api/agent/images/edit', - create_generate_job: '/api/agent/jobs/images/generate', - job: '/api/agent/jobs/{id}', - job_result: '/api/agent/jobs/{id}/result', - artifact_metadata: '/api/agent/artifacts/{id}', - artifact_content: '/api/agent/artifacts/{id}/content', - artifact_delete: '/api/agent/artifacts/{id}' - }, + endpoints: { ...AGENT_ENDPOINTS }, defaults: { model: 'gpt-image-2', response_mode: 'path', - state_backend: readAgentStateBackend(env) + state_backend: readAgentStateBackend(env), + image_backend: 'images-api', + streaming_strategy: 'off', + partial_images: 2 }, limits: { max_prompt_length: MAX_PROMPT_LENGTH, @@ -467,9 +656,9 @@ export function buildAgentCapabilities(env: Record): { name: 'landscape', size: '3072x2048', purpose: '横向宽幅构图' }, { name: 'portrait', size: '2048x3072', purpose: '纵向主体构图' } ], - high_4k_risk: { - applies_to: ['quality=high', 'max_edge>=3072', 'long_running_upstream'], - guidance: '高质量 4K 级请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。' + large_image_risk: { + applies_to: ['max_edge>2048', 'long_running_upstream'], + guidance: '大尺寸请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。' } } }, @@ -477,32 +666,93 @@ export function buildAgentCapabilities(env: Record): generate: { supported: false, mode: 'non_streaming_only', - endpoint: '/api/agent/images/generate' + endpoint: AGENT_ENDPOINTS.generate }, edit: { supported: false, mode: 'non_streaming_only', - endpoint: '/api/agent/images/edit' + endpoint: AGENT_ENDPOINTS.edit + }, + upstream_sse: { + supported: true, + mode: 'internal_upstream_sse', + endpoint: AGENT_ENDPOINTS.generate, + request_fields: ['image_backend', 'streaming_strategy', 'partial_images'], + image_backends: AGENT_IMAGE_BACKENDS, + enabled_image_backends: enabledImageBackends, + streaming_strategies: AGENT_STREAMING_STRATEGIES, + activation_strategies: AGENT_UPSTREAM_SSE_ACTIVATION_STRATEGIES, + final_response_contract: 'AgentImageResponse' }, page_sse: { supported: true, mode: 'form_data_sse', endpoint: '/api/images', - contract: 'page_ui_only' + contract: 'page_ui_only', + transport_contract: 'page_form_data_sse', + auth: buildPageSseAuthCapabilities(env), + client_request_id: { + form_field: 'clientRequestId', + source_header: 'Idempotency-Key', + max_length: PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH + }, + agent_usage: 'recommended_for_high_resolution_generate_edit_and_complex_batch' + } + }, + routing_rules: { + high_resolution_edit: { + when: ['operation=edit', 'max_edge>2048'], + endpoint: '/api/images', + transport: 'page_sse', + strength: 'must_use', + reason: 'Agent edit is non-streaming and rejects high-resolution edit requests; use the page form-data SSE endpoint instead.' + }, + complex_ui_batch: { + when: ['operation=generate_or_edit', 'complex_ui=true', 'batch=true'], + endpoint: '/api/images', + transport: 'page_sse', + strength: 'recommended', + reason: 'Page form-data SSE keeps long-running image production observable and recoverable for complex UI batches.' + }, + long_image_recovery: { + when: ['operation=generate_or_edit', 'long_image=true', 'resume_or_recover=true'], + endpoint: '/api/images', + transport: 'page_sse', + strength: 'recommended', + reason: 'Page form-data SSE exposes partial progress and final-image diagnostics needed for long-image recovery runs.' + }, + agent_generate_small_smoke: { + when: ['operation=generate', 'max_edge<=2048', 'single_request=true'], + endpoint: AGENT_ENDPOINTS.generate, + transport: 'agent_json', + strength: 'default', + reason: 'Agent JSON generate remains the stable contract and smoke path for normal single-image requests.' + }, + page_sse_large_generate: { + when: ['operation=generate', 'max_edge>2048', 'single_request=true'], + endpoint: '/api/images', + transport: 'page_sse', + strength: 'recommended', + reason: 'Page form-data SSE keeps large generate requests observable; if the stream fails, diagnose first and choose any Agent JSON retry explicitly.' + }, + retry_recovery: { + reuse_failed_idempotency_key: false, + new_attempt_guidance: + 'A failed terminal Agent request only replays the stored failure. Diagnose the failure, then create a new business operation with a new Idempotency-Key.' } }, agent_jobs: { supported: true, mode: 'job_polling', - intended_for: ['quality=high', 'max_edge>=3072', 'long_running_upstream', 'manual_billable_gate'], - endpoints: { - create_generate_job: '/api/agent/jobs/images/generate', - get_job: '/api/agent/jobs/{id}', - get_job_result: '/api/agent/jobs/{id}/result' - }, + intended_for: [ + 'explicit_job_route', + 'manual_after_diagnosis', + 'long_running_upstream_when_page_sse_not_selected' + ], + endpoints: { ...AGENT_JOB_ENDPOINTS }, states: AGENT_JOB_STATES, current_guidance: - '对 4K/high 或长耗时请求优先使用 job polling:先创建 generate job,再轮询状态,最后读取 result。运行中 job 会刷新 lease。当前执行模型为同实例后台任务,不是跨实例持久队列。' + '对 max_edge>2048 的单次文生图请求优先使用页面端 /api/images SSE;如果页面流式失败,先诊断再显式选择 Agent JSON 或 job 路径,不自动回退。job polling 仅用于显式 job 路径。当前执行模型为同实例后台任务,不是跨实例持久队列。' }, supported: { models: AGENT_MODELS, @@ -511,7 +761,11 @@ export function buildAgentCapabilities(env: Record): qualities: AGENT_QUALITIES, backgrounds: AGENT_BACKGROUNDS, moderations: AGENT_MODERATIONS, - legacy_sizes: AGENT_LEGACY_SIZES + legacy_sizes: AGENT_LEGACY_SIZES, + image_backends: AGENT_IMAGE_BACKENDS, + enabled_image_backends: enabledImageBackends, + image_backend_requirements: imageBackendRequirements, + streaming_strategies: AGENT_STREAMING_STRATEGIES }, storage: { image_storage_mode: env.NEXT_PUBLIC_IMAGE_STORAGE_MODE || (env.VERCEL === '1' ? 'indexeddb' : 'fs'), diff --git a/src/lib/agent-api-paths.mjs b/src/lib/agent-api-paths.mjs new file mode 100644 index 0000000000000000000000000000000000000000..9a892247f6e4748cb7f20ae8d30b0aff81c96515 --- /dev/null +++ b/src/lib/agent-api-paths.mjs @@ -0,0 +1,30 @@ +export const AGENT_ENDPOINTS = Object.freeze({ + capabilities: '/api/agent/capabilities', + openapi: '/api/agent/openapi.json', + generate: '/api/agent/images/generate', + edit: '/api/agent/images/edit', + create_generate_job: '/api/agent/jobs/images/generate', + job: '/api/agent/jobs/{id}', + job_result: '/api/agent/jobs/{id}/result', + artifact_metadata: '/api/agent/artifacts/{id}', + artifact_content: '/api/agent/artifacts/{id}/content', + artifact_delete: '/api/agent/artifacts/{id}' +}); + +export const AGENT_JOB_ENDPOINTS = Object.freeze({ + create_generate_job: AGENT_ENDPOINTS.create_generate_job, + get_job: AGENT_ENDPOINTS.job, + get_job_result: AGENT_ENDPOINTS.job_result +}); + +export function buildAgentJobPath(jobId) { + return AGENT_ENDPOINTS.job.replace('{id}', encodePathValue(jobId)); +} + +export function buildAgentJobResultPath(jobId) { + return AGENT_ENDPOINTS.job_result.replace('{id}', encodePathValue(jobId)); +} + +function encodePathValue(value) { + return encodeURIComponent(String(value)); +} diff --git a/src/lib/agent-image-service.test.ts b/src/lib/agent-image-service.test.ts index 5e16744dcc72c8826073e7d2f54a93318daba5db..c69b184ff8cea45f7f01aac2f44e69b279b3522d 100644 --- a/src/lib/agent-image-service.test.ts +++ b/src/lib/agent-image-service.test.ts @@ -1,4 +1,9 @@ -import { buildEditRequestHash, completeAgentExecutionState, hydrateAgentReplayResponse } from './agent-image-service'; +import { + assertAgentEditRouteAllowedFromFormData, + buildEditRequestHash, + completeAgentExecutionState, + hydrateAgentReplayResponse +} from './agent-image-service'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import type { AgentArtifactRecord, AgentStateStore } from './agent-state-store'; @@ -19,6 +24,15 @@ describe('buildEditRequestHash', () => { }); }); +describe('assertAgentEditRouteAllowedFromFormData', () => { + it('rejects auto-size edits when the source image dimensions are high resolution', async () => { + await assert.rejects( + () => assertAgentEditRouteAllowedFromFormData(makeEditFormWithImage(createPngWithDimensions(3072, 2048))), + /\/api\/images/ + ); + }); +}); + describe('hydrateAgentReplayResponse', () => { it('rejects base64 replay when stored artifact filepath escapes the image directory', async () => { const store = createReplayStore([ @@ -119,14 +133,28 @@ describe('completeAgentExecutionState', () => { }); function makeEditForm(bytes: number[]): FormData { + return makeEditFormWithImage(Buffer.from(bytes)); +} + +function makeEditFormWithImage(buffer: Buffer): FormData { const formData = new FormData(); formData.append('prompt', 'same prompt'); formData.append('model', 'gpt-image-2'); formData.append('response_mode', 'path'); - formData.append('image_0', new File([Buffer.from(bytes)], 'input.png', { type: 'image/png' })); + formData.append('image_0', new File([buffer], 'input.png', { type: 'image/png' })); return formData; } +function createPngWithDimensions(width: number, height: number): Buffer { + const buffer = Buffer.alloc(24); + buffer[0] = 0x89; + buffer.write('PNG', 1, 'ascii'); + buffer.write('IHDR', 12, 'ascii'); + buffer.writeUInt32BE(width, 16); + buffer.writeUInt32BE(height, 20); + return buffer; +} + function createReplayStore(artifacts: AgentArtifactRecord[]): AgentStateStore { return { async init() {}, diff --git a/src/lib/agent-image-service.ts b/src/lib/agent-image-service.ts index a6942c78c92fc10342cb26dc752f80c7943bff6b..f2e9d7305bbddf78006fffcfa4bef2de3bfe65fc 100644 --- a/src/lib/agent-image-service.ts +++ b/src/lib/agent-image-service.ts @@ -1,10 +1,3 @@ -import { - type ChannelCredential, - describeChannelFailure, - isChannelFailure, - isCredentialFailure, - resolveEffectiveCredential -} from './channel-router'; import { type AgentGenerateRequest, type AgentImageResponse, @@ -12,8 +5,7 @@ import { type AgentResponseMode, validateAgentGenerateRequest } from './agent-api-contracts'; -import { AgentApiError, normalizeAgentError, type AgentErrorBody } from './api-error-response'; -import { assertArtifactFilepathAllowed, deleteArtifactFileIfAllowed } from './agent-file-utils'; +import { assertArtifactFilepathAllowed, deleteArtifactFileIfAllowed, readImageDimensions } from './agent-file-utils'; import { artifactRecordToResponseItem, createArtifactId, @@ -23,13 +15,16 @@ import { type AgentArtifactRecord, type AgentStateStore } from './agent-state-store'; +import { AgentApiError, normalizeAgentError, type AgentErrorBody } from './api-error-response'; +import type { AgentErrorDiagnostics } from './api-error-response'; import { appLogger } from './app-logger'; -import fs from 'fs/promises'; import { - InvalidOpenAiImagesResponseError, - MissingOpenAiImageDataError, - persistOpenAiImages as persistSharedOpenAiImages -} from './image-service'; + type ChannelCredential, + describeChannelFailure, + isChannelFailure, + isCredentialFailure, + resolveEffectiveCredential +} from './channel-router'; import { RequestValidationError, readCount, @@ -43,11 +38,23 @@ import { type GptImageModel, type ValidOutputFormat } from './image-request-utils'; +import { + InvalidOpenAiImagesResponseError, + MissingOpenAiImageDataError, + persistOpenAiImages as persistSharedOpenAiImages +} from './image-service'; +import { collectOpenAiImagesFromStream } from './image-stream-collector'; +import { createImagesApiGenerateStream } from './images-api-stream'; +import { + createResponsesImageStream, + generateImageWithResponsesBackend, + type ResponsesImageGenerateInput +} from './responses-image-backend'; import { getServerChannelState } from './server-channel-router'; -import { readAffinityKey } from './server-runtime'; +import { readAffinityKey, readBooleanEnv } from './server-runtime'; import crypto from 'crypto'; +import fs from 'fs/promises'; import OpenAI from 'openai'; -import type { AgentErrorDiagnostics } from './api-error-response'; export type AgentRequestExecutionResult = { response: AgentImageResponse; @@ -59,6 +66,7 @@ type CredentialContext = { openai: OpenAI; selectedCredential?: ChannelCredential; baseUrl?: string; + apiKey: string; }; export function readIdempotencyKey(headers: Headers): string { @@ -109,6 +117,12 @@ export async function snapshotAgentEditFormData(formData: FormData): Promise a.key.localeCompare(b.key)) }; } +export async function assertAgentEditRouteAllowedFromFormData(formData: FormData): Promise { + const model = readModel(formData); + const size = readSize(formData, 'size', 'auto', model); + assertAgentEditRouteAllowed(size, await readAgentEditSourceMaxEdge(size, formData)); +} + async function snapshotFileField( key: string, file: File @@ -129,24 +143,16 @@ export async function executeAgentGenerate(options: { requestId: string; idempotencyKey: string; cached: boolean; + abortSignal?: AbortSignal; }): Promise { const credentialContext = createOpenAiClient(options.headers); const startedAtMs = Date.now(); try { - const result = await credentialContext.openai.images.generate({ - model: options.request.model, - prompt: options.request.prompt, - n: options.request.n, - size: options.request.size as OpenAI.Images.ImageGenerateParams['size'], - quality: options.request.quality as OpenAI.Images.ImageGenerateParams['quality'], - output_format: options.request.output_format, - background: options.request.background as OpenAI.Images.ImageGenerateParams['background'], - moderation: options.request.moderation as OpenAI.Images.ImageGenerateParams['moderation'], - ...(options.request.output_compression !== undefined - ? { output_compression: options.request.output_compression } - : {}), - stream: false - }); + const result = await executeAgentGenerateUpstream( + options.request, + credentialContext, + options.abortSignal + ); return await persistOpenAiImages({ result, mode: 'generate', @@ -164,14 +170,114 @@ export async function executeAgentGenerate(options: { } } +async function executeAgentGenerateUpstream( + request: AgentGenerateRequest, + credentialContext: CredentialContext, + abortSignal?: AbortSignal +): Promise { + const { openai } = credentialContext; + if (request.image_backend === 'responses-image-generation') { + return executeAgentResponsesGenerate(request, openai, abortSignal); + } + const baseParams = { + model: request.model, + prompt: request.prompt, + n: request.n, + size: request.size as OpenAI.Images.ImageGenerateParams['size'], + quality: request.quality as OpenAI.Images.ImageGenerateParams['quality'], + output_format: request.output_format, + background: request.background as OpenAI.Images.ImageGenerateParams['background'], + moderation: request.moderation as OpenAI.Images.ImageGenerateParams['moderation'], + ...(request.output_compression !== undefined ? { output_compression: request.output_compression } : {}) + }; + if (!shouldUseAgentUpstreamStream(request)) { + return openai.images.generate( + { ...baseParams, stream: false }, + abortSignal ? { signal: abortSignal } : undefined + ); + } + const stream = await createImagesApiGenerateStream({ + apiBaseUrl: credentialContext.baseUrl, + apiKey: credentialContext.apiKey, + abortSignal, + params: { + ...baseParams, + stream: true, + partial_images: request.partial_images + } + }); + return collectOpenAiImagesFromStream(stream); +} + +async function executeAgentResponsesGenerate( + request: AgentGenerateRequest, + openai: OpenAI, + abortSignal?: AbortSignal +): Promise { + if (!readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { + throw new RequestValidationError( + 'Responses API 图片后端仍是实验能力,必须设置 ENABLE_RESPONSES_IMAGE_BACKEND=true 后才能使用。', + 400 + ); + } + if (request.n !== 1) { + throw new RequestValidationError('Responses API 图片后端当前只支持单张生成。', 400); + } + const input: ResponsesImageGenerateInput = { + responses: openai.responses, + prompt: request.prompt, + responsesModel: readAgentResponsesApiModel(), + imageModel: request.model, + size: readAgentResponsesImageSize(request.size), + quality: request.quality, + outputFormat: request.output_format, + background: request.background, + moderation: request.moderation, + abortSignal, + ...(request.output_compression !== undefined ? { outputCompression: request.output_compression } : {}) + }; + if (!shouldUseAgentUpstreamStream(request)) { + return generateImageWithResponsesBackend(input); + } + return collectOpenAiImagesFromStream( + await createResponsesImageStream({ ...input, partialImagesCount: request.partial_images }) + ); +} + +function shouldUseAgentUpstreamStream(request: AgentGenerateRequest): boolean { + return request.streaming_strategy !== 'off' && request.streaming_strategy !== 'auto'; +} + +function readAgentResponsesApiModel(): string { + const model = process.env.OPENAI_RESPONSES_API_MODEL?.trim(); + if (!model) { + throw new RequestValidationError( + 'Responses API 图片后端必须配置 OPENAI_RESPONSES_API_MODEL,作为 /responses 顶层模型。', + 500 + ); + } + return model; +} + +function readAgentResponsesImageSize(size: string): ResponsesImageGenerateInput['size'] { + if (size === 'auto' || size === '1024x1024' || size === '1024x1536' || size === '1536x1024') { + return size; + } + throw new RequestValidationError( + 'Responses API 图片后端当前只支持 auto、1024x1024、1024x1536 或 1536x1024 尺寸。', + 400 + ); +} + export async function executeAgentEdit(options: { formData: FormData; headers: Headers; requestId: string; idempotencyKey: string; cached: boolean; + abortSignal?: AbortSignal; }): Promise { - const credentialContext = createOpenAiClient(options.headers); + let credentialContext: CredentialContext | undefined; const startedAtMs = Date.now(); try { const prompt = readRequiredText(options.formData, 'prompt'); @@ -181,17 +287,22 @@ export async function executeAgentEdit(options: { const quality = readEditQuality(options.formData) as OpenAI.Images.ImageEditParams['quality']; const responseMode = readAgentResponseModeFromForm(options.formData); const imageFiles = readImageFiles(options.formData); + assertAgentEditRouteAllowed(size, await readImageFileMaxEdge(imageFiles)); const maskFile = readMaskFile(options.formData); - const result = await credentialContext.openai.images.edit({ - model, - prompt, - image: imageFiles, - n, - size: size === 'auto' ? undefined : size, - quality: quality === 'auto' ? undefined : quality, - ...(maskFile ? { mask: maskFile } : {}) - }); + credentialContext = createOpenAiClient(options.headers); + const result = await credentialContext.openai.images.edit( + { + model, + prompt, + image: imageFiles, + n, + size: size === 'auto' ? undefined : size, + quality: quality === 'auto' ? undefined : quality, + ...(maskFile ? { mask: maskFile } : {}) + }, + options.abortSignal ? { signal: options.abortSignal } : undefined + ); return await persistOpenAiImages({ result, @@ -205,11 +316,40 @@ export async function executeAgentEdit(options: { cached: options.cached }); } catch (error) { - reportServerCredentialFailure(credentialContext.selectedCredential, error); + reportServerCredentialFailure(credentialContext?.selectedCredential, error); throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs)); } } +function assertAgentEditRouteAllowed(size: string | null | undefined, sourceMaxEdge = 0): void { + const maxEdge = Math.max(readMaxImageEdge(size), sourceMaxEdge); + if (maxEdge <= 2048) return; + throw new RequestValidationError('高分辨率 Agent edit 必须使用页面端 /api/images form-data SSE 路径。', 422); +} + +async function readAgentEditSourceMaxEdge(size: string | null | undefined, formData: FormData): Promise { + if (readMaxImageEdge(size) > 2048 || size !== 'auto') return 0; + const imageFiles = readImageFiles(formData); + return readImageFileMaxEdge(imageFiles); +} + +async function readImageFileMaxEdge(imageFiles: File[]): Promise { + const maxEdges = await Promise.all( + imageFiles.map(async (file) => { + const dimensions = readImageDimensions(Buffer.from(await file.arrayBuffer())); + return Math.max(dimensions.width ?? 0, dimensions.height ?? 0); + }) + ); + return Math.max(0, ...maxEdges); +} + +function readMaxImageEdge(size: string | null | undefined): number { + if (typeof size !== 'string') return 0; + const match = size.match(/^(\d+)x(\d+)$/); + if (!match) return 0; + return Math.max(Number(match[1]), Number(match[2])); +} + export async function parseAgentGenerateRequest(request: Request): Promise { const contentType = request.headers.get('content-type') || ''; if (!contentType.includes('application/json')) { @@ -345,7 +485,11 @@ export async function hydrateAgentReplayResponse( function createOpenAiClient(headers: Headers): CredentialContext { const serverChannelRouter = getServerChannelState().router; const selectedCredential = serverChannelRouter?.select({ affinityKey: readAffinityKey(headers) }); - const { apiKey, baseUrl, selectedCredential: effectiveSelectedCredential } = resolveEffectiveCredential({ + const { + apiKey, + baseUrl, + selectedCredential: effectiveSelectedCredential + } = resolveEffectiveCredential({ requestApiKey: '', requestApiBaseUrl: '', legacyBaseUrl: process.env.OPENAI_API_BASE_URL, @@ -366,15 +510,21 @@ function createOpenAiClient(headers: Headers): CredentialContext { baseURL: baseUrl || undefined }), selectedCredential: effectiveSelectedCredential, - baseUrl + baseUrl, + apiKey }; } -function buildAgentExecutionDiagnostics(context: CredentialContext, startedAtMs: number): AgentErrorDiagnostics { - const upstreamHost = context.baseUrl ? readUrlHost(context.baseUrl) : undefined; +function buildAgentExecutionDiagnostics( + context: CredentialContext | undefined, + startedAtMs: number +): AgentErrorDiagnostics { + const upstreamHost = context?.baseUrl ? readUrlHost(context.baseUrl) : undefined; return { elapsed_ms: Date.now() - startedAtMs, - ...(context.selectedCredential?.channelId ? { selected_channel_id: context.selectedCredential.channelId } : {}), + ...(context?.selectedCredential?.channelId + ? { selected_channel_id: context.selectedCredential.channelId } + : {}), ...(upstreamHost ? { upstream_host: upstreamHost } : {}) }; } @@ -399,7 +549,10 @@ function reportServerCredentialFailure(credential: ChannelCredential | undefined if (isCredentialFailure(error)) { const reason = describeChannelFailure(error, 'credential'); serverChannelRouter.reportFailure(credential, { reason }); - appLogger.warn(`Temporarily cooling down API channel credential: ${credential.channelId}/${credential.id}`, reason); + appLogger.warn( + `Temporarily cooling down API channel credential: ${credential.channelId}/${credential.id}`, + reason + ); } } diff --git a/src/lib/agent-job-service.ts b/src/lib/agent-job-service.ts index 250f4a540efa5ea04f454e76092919f5b28d78b5..3f8d65100d81840bc3f744e45e4e2e77c0507182 100644 --- a/src/lib/agent-job-service.ts +++ b/src/lib/agent-job-service.ts @@ -2,6 +2,7 @@ import type { AgentGenerateRequest, AgentJobState, AgentJobStatusResponse } from import { completeAgentExecutionState, createArtifactPersistenceError, + createCompletionPersistenceError, deleteAgentExecutionFiles, errorToAgentErrorBody, executeAgentGenerate, @@ -10,6 +11,7 @@ import { } from './agent-image-service'; import { AgentApiError, toTerminalAgentErrorBody, type AgentErrorBody } from './api-error-response'; import type { AgentRequestRecord, AgentStateStore } from './agent-state-store'; +import { buildAgentJobResultPath } from './agent-api-paths.mjs'; import { appLogger } from './app-logger'; const DEFAULT_JOB_RETRY_AFTER_SECONDS = 5; @@ -30,7 +32,7 @@ export function buildAgentJobStatusResponse( created_at: record.createdAt, updated_at: record.updatedAt, expires_at: record.expiresAt, - ...(state !== 'expired' ? { result_url: `/api/agent/jobs/${record.requestId}/result` } : {}), + ...(state !== 'expired' ? { result_url: buildAgentJobResultPath(record.requestId) } : {}), ...(state === 'running' || state === 'queued' ? { retry_after_seconds: options.retryAfterSeconds ?? DEFAULT_JOB_RETRY_AFTER_SECONDS } : {}), @@ -70,7 +72,7 @@ export function assertReadableJobRecord(record: AgentRequestRecord | undefined, return record; } -export async function readCompletedJobResult( +export async function readJobResult( store: AgentStateStore, record: AgentRequestRecord ): Promise { @@ -112,8 +114,9 @@ async function runAgentGenerateJob(options: { idempotencyKey: string; leaseMs: number; }): Promise { - const heartbeat = startAgentJobLeaseHeartbeat(options.store, options.requestId, options.leaseMs); + let heartbeat: { stop: () => void } | undefined; try { + heartbeat = startAgentJobLeaseHeartbeat(options.store, options.requestId, options.leaseMs); const execution = await executeAgentGenerate({ request: options.request, headers: options.headers, @@ -125,11 +128,14 @@ async function runAgentGenerateJob(options: { } catch (error) { await failAgentJob(options.store, options.requestId, error); } finally { - heartbeat.stop(); + heartbeat?.stop(); } } function startAgentJobLeaseHeartbeat(store: AgentStateStore, requestId: string, leaseMs: number): { stop: () => void } { + if (!Number.isFinite(leaseMs) || leaseMs <= 0) { + throw new Error('leaseMs must be a positive number.'); + } const intervalMs = Math.max(100, Math.min(MAX_JOB_LEASE_REFRESH_INTERVAL_MS, Math.floor(leaseMs / 2))); const timer = setInterval(() => { void store.refreshRequestLease({ requestId, leaseMs }).catch((error) => { @@ -158,6 +164,7 @@ async function persistAgentJobSuccess( await completeAgentExecutionState(store, execution); } catch (error) { appLogger.error('保存 Agent job 完成状态失败。', error); + await failAgentJob(store, requestId, createCompletionPersistenceError()); } } diff --git a/src/lib/agent-openapi.ts b/src/lib/agent-openapi.ts index b2335f1054b624f0b501ce29de5affe915873de2..ca98a1910e293878b8d367009739c1a719e473dd 100644 --- a/src/lib/agent-openapi.ts +++ b/src/lib/agent-openapi.ts @@ -1,16 +1,22 @@ -import { MAX_IMAGE_COUNT, MAX_PROMPT_LENGTH } from './image-request-utils'; import { AGENT_BACKGROUNDS, + AGENT_IMAGE_BACKENDS, AGENT_JOB_STATES, AGENT_MODERATIONS, AGENT_MODELS, AGENT_OUTPUT_FORMATS, AGENT_QUALITIES, AGENT_RESPONSE_MODES, + AGENT_STREAMING_STRATEGIES, + AGENT_UPSTREAM_SSE_ACTIVATION_STRATEGIES, type AgentAuthScheme, + type AgentRoutingStrength, + type AgentRoutingTransport, buildAgentCapabilities, readAgentPublicBaseUrl } from './agent-api-contracts'; +import { AGENT_ENDPOINTS } from './agent-api-paths.mjs'; +import { MAX_IMAGE_COUNT, MAX_PROMPT_LENGTH } from './image-request-utils'; type AgentOpenApiSecurityRequirement = { BearerAuth: [] } | { AppPasswordHash: [] }; @@ -62,7 +68,7 @@ export function buildAgentOpenApiDocument(env: Record { setAgentStateStoreFactoryForTests(undefined); @@ -200,7 +201,8 @@ describe('readAgentDatabaseUrl', () => { }); }); -function createFakeStore(options: { failFirstRecovery?: boolean; failInit?: () => boolean } = {}): AgentStateStore & { +function createFakeStore(options: { failFirstRecovery?: boolean; failInit?: () => boolean } = {}): AgentStateStore & + ImageShareStateStore & { recoveryCalls: number; initCalls: number; shareCleanupCalls: number; @@ -237,12 +239,19 @@ function createFakeStore(options: { failFirstRecovery?: boolean; failInit?: () = async getArtifact() { return undefined; }, + async getRequest() { + return undefined; + }, async listArtifactsForRequest() { return []; }, async deleteArtifact() { return false; }, + async createImageShareRecord() {}, + async readImageShareRecord() { + return undefined; + }, async deleteExpiredImageShareRecords() { this.shareCleanupCalls += 1; return []; diff --git a/src/lib/agent-state-sqlite.test.ts b/src/lib/agent-state-sqlite.test.ts index 60d91cc5ff9834195a10a8de0266969549247a09..c74d35210811d4a2155d077dcf6b12c56ae2014f 100644 --- a/src/lib/agent-state-sqlite.test.ts +++ b/src/lib/agent-state-sqlite.test.ts @@ -1,5 +1,6 @@ import { SQLITE_SCHEMA, SqliteAgentStateStore } from './agent-state-sqlite'; -import { hashAgentPayload, type AgentArtifactRecord, type AgentImageResponse } from './agent-state-store'; +import type { AgentImageResponse } from './agent-api-contracts'; +import { hashAgentPayload, type AgentArtifactRecord } from './agent-state-store'; import assert from 'node:assert/strict'; import { access, mkdir, mkdtemp, readdir, rm, writeFile } from 'node:fs/promises'; import Database from 'better-sqlite3'; diff --git a/src/lib/api-error-response.test.ts b/src/lib/api-error-response.test.ts index 447f891f3fed3409c325f1901099d9d5bfcd39b5..78c9128ad1a7872e4b1a1cd0410f5e2d6ba32a5e 100644 --- a/src/lib/api-error-response.test.ts +++ b/src/lib/api-error-response.test.ts @@ -1,4 +1,10 @@ -import { AgentApiError, createAgentErrorBody, normalizeAgentError } from './api-error-response'; +import { + AgentApiError, + agentErrorResponse, + createAgentErrorBody, + normalizeAgentError, + toTerminalAgentErrorBody +} from './api-error-response'; import { RequestValidationError } from './image-request-utils'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; @@ -151,4 +157,62 @@ describe('createAgentErrorBody', () => { } }); }); + + it('removes retry timing from terminal failed replays', () => { + const terminal = toTerminalAgentErrorBody({ + error: { + code: 'upstream_unavailable', + message: 'Connection error.', + retryable: true, + diagnostics: { + retry_after_seconds: 15, + transport_error: true, + channel_cooldown_scope: 'channel' + }, + request_id: 'request-terminal' + } + }); + + assert.equal(terminal.error.retryable, false); + assert.equal(terminal.error.diagnostics?.retry_after_seconds, undefined); + assert.equal(terminal.error.diagnostics?.transport_error, true); + assert.equal(terminal.error.diagnostics?.channel_cooldown_scope, 'channel'); + }); + + it('removes retry timing from any non-retryable error body', () => { + const body = createAgentErrorBody( + new AgentApiError({ + code: 'unexpected_error', + message: 'terminal', + status: 500, + retryable: false, + diagnostics: { + retry_after_seconds: 10, + upstream_event_type: 'image_generation.partial_image', + partial_image_count: 1 + } + }), + 'request-non-retryable' + ); + + assert.equal(body.error.retryable, false); + assert.equal(body.error.diagnostics?.retry_after_seconds, undefined); + assert.equal(body.error.diagnostics?.upstream_event_type, 'image_generation.partial_image'); + assert.equal(body.error.diagnostics?.partial_image_count, 1); + }); + + it('does not emit Retry-After headers for non-retryable errors', () => { + const response = agentErrorResponse( + new AgentApiError({ + code: 'unexpected_error', + message: 'terminal', + status: 500, + retryable: false, + retryAfterSeconds: 10 + }), + 'request-non-retryable-header' + ); + + assert.equal(response.headers.get('Retry-After'), null); + }); }); diff --git a/src/lib/api-error-response.ts b/src/lib/api-error-response.ts index 8f22f85a59843d71579b2a89bafc08de43401667..26d4d3279efe7e95eaa3e55eadcc36f78737bf01 100644 --- a/src/lib/api-error-response.ts +++ b/src/lib/api-error-response.ts @@ -22,6 +22,8 @@ export type AgentErrorDiagnostics = { selected_channel_id?: string; upstream_host?: string; upstream_status?: number; + upstream_event_type?: string; + partial_image_count?: number; transport_error?: boolean; retry_after_seconds?: number; channel_cooldown_scope?: 'credential' | 'channel'; @@ -106,11 +108,15 @@ function buildDiagnostics(error: unknown, input: ErrorDiagnosticsInput = {}): Ag const { upstreamStatus, retryAfterSeconds, ...base } = input; const upstreamStatusValue = base.upstream_status ?? upstreamStatus; const retryAfterSecondsValue = base.retry_after_seconds ?? retryAfterSeconds ?? readRetryAfterSeconds(error); + const upstreamEventType = base.upstream_event_type ?? readStringField(error, 'upstreamEventType'); + const partialImageCount = base.partial_image_count ?? readNumberField(error, 'partialImageCount'); const transportError = base.transport_error ?? (isTransportError(error) || undefined); const responseHeaders = base.response_headers ?? readWhitelistedHeaders(error); return cleanDiagnostics({ ...base, ...(upstreamStatusValue !== undefined ? { upstream_status: upstreamStatusValue } : {}), + ...(upstreamEventType !== undefined ? { upstream_event_type: upstreamEventType } : {}), + ...(partialImageCount !== undefined ? { partial_image_count: partialImageCount } : {}), ...(retryAfterSecondsValue !== undefined ? { retry_after_seconds: retryAfterSecondsValue } : {}), ...(transportError !== undefined ? { transport_error: transportError } : {}), ...(responseHeaders ? { response_headers: responseHeaders } : {}) @@ -124,11 +130,17 @@ function cleanDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): Agent diagnostics.retry_after_seconds !== undefined ? normalizeRetryAfterSeconds(diagnostics.retry_after_seconds) : undefined; + const partialImageCount = + diagnostics.partial_image_count !== undefined + ? normalizeNonNegativeInteger(diagnostics.partial_image_count) + : undefined; const cleaned: AgentErrorDiagnostics = { ...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}), ...(diagnostics.selected_channel_id ? { selected_channel_id: diagnostics.selected_channel_id } : {}), ...(diagnostics.upstream_host ? { upstream_host: diagnostics.upstream_host } : {}), ...(diagnostics.upstream_status !== undefined ? { upstream_status: diagnostics.upstream_status } : {}), + ...(diagnostics.upstream_event_type ? { upstream_event_type: diagnostics.upstream_event_type } : {}), + ...(partialImageCount !== undefined ? { partial_image_count: partialImageCount } : {}), ...(diagnostics.transport_error !== undefined ? { transport_error: diagnostics.transport_error } : {}), ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}), ...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}), @@ -166,6 +178,13 @@ function normalizeRetryAfterSeconds(value: number): number | undefined { return rounded; } +function normalizeNonNegativeInteger(value: number): number | undefined { + if (!Number.isFinite(value)) return undefined; + const rounded = Math.round(value); + if (!Number.isSafeInteger(rounded) || rounded < 0) return undefined; + return rounded; +} + function readWhitelistedHeaders(error: unknown): Record | undefined { const headers = readHeadersSource(error); return readWhitelistedHeaderSource(headers); @@ -265,6 +284,7 @@ function readCauseChainString(error: unknown, fieldName: string, depth = 0): str } export function createAgentErrorBody(error: AgentApiError, requestId: string): AgentErrorBody { + const diagnostics = error.retryable ? error.diagnostics : stripRetryDiagnostics(error.diagnostics); return { error: { code: error.code, @@ -272,7 +292,7 @@ export function createAgentErrorBody(error: AgentApiError, requestId: string): A retryable: error.retryable, ...(error.details ? { details: error.details } : {}), ...(error.upstreamStatus ? { upstream_status: error.upstreamStatus } : {}), - ...(error.diagnostics ? { diagnostics: error.diagnostics } : {}), + ...(diagnostics ? { diagnostics } : {}), request_id: requestId } }; @@ -282,7 +302,7 @@ export function agentErrorResponse(error: AgentApiError, requestId: string): Nex const headers: Record = { 'X-Request-Id': requestId }; - if (error.retryAfterSeconds) { + if (error.retryable && error.retryAfterSeconds) { headers['Retry-After'] = String(error.retryAfterSeconds); } return NextResponse.json(createAgentErrorBody(error, requestId), { status: error.status, headers }); @@ -302,12 +322,28 @@ export function storedAgentErrorResponse( export function toTerminalAgentErrorBody(errorBody: AgentErrorBody): AgentErrorBody { return { error: { - ...errorBody.error, + ...stripTerminalRetryDiagnostics(errorBody.error), retryable: false } }; } +function stripTerminalRetryDiagnostics(error: AgentErrorBody['error']): AgentErrorBody['error'] { + if (!error.diagnostics?.retry_after_seconds) return error; + const diagnostics = stripRetryDiagnostics(error.diagnostics); + return { + ...error, + diagnostics + }; +} + +function stripRetryDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): AgentErrorDiagnostics | undefined { + if (!diagnostics?.retry_after_seconds) return diagnostics; + const stripped = { ...diagnostics }; + delete stripped.retry_after_seconds; + return Object.keys(stripped).length > 0 ? stripped : undefined; +} + export function normalizeAgentError(error: unknown, diagnostics: AgentErrorDiagnostics = {}): AgentApiError { if (error instanceof AgentApiError) return error; if (error instanceof RequestValidationError) { diff --git a/src/lib/app-logger.test.ts b/src/lib/app-logger.test.ts index e81659d368ef4dad59df0b05d2925de6540d4c87..12ddfdaa1aa16574e94e30761f53645e7a2ede7d 100644 --- a/src/lib/app-logger.test.ts +++ b/src/lib/app-logger.test.ts @@ -132,11 +132,14 @@ describe('appLogger', { concurrency: false }, () => { const entries = readAppLogEntries(); assert.equal(entries.length, 2); - assert.equal(entries[0].level, 'info'); - assert.equal(entries[0].message, 'visible info'); - assert.equal(typeof entries[0].context, 'string'); - assert.equal(entries[0].clientRequestId, undefined); - assert.match(entries[0].context, /"requestId": "req-3"/); + const firstEntry = entries[0]; + assert.ok(firstEntry); + assert.equal(firstEntry.level, 'info'); + assert.equal(firstEntry.message, 'visible info'); + assert.equal(typeof firstEntry.context, 'string'); + assert.equal(firstEntry.clientRequestId, undefined); + assert.ok(firstEntry.context); + assert.match(firstEntry.context, /"requestId": "req-3"/); assert.deepEqual(received, ['info:visible info']); }); diff --git a/src/lib/i18n.tsx b/src/lib/i18n.tsx index 9d86b91b66e60558cd301852fec0b7a3dd1ed2b3..da01f1efdd4f89eb2f01882733c307385b0cdde5 100644 --- a/src/lib/i18n.tsx +++ b/src/lib/i18n.tsx @@ -76,6 +76,20 @@ const messages: Record> = { 'streaming.description': '生成过程中显示局部预览图,交互反馈更及时。', 'streaming.batchDescription': '并发批处理已启用:每张图会拆成独立流式任务。', 'streaming.costHint': '每张预览图约增加约 $0.003 成本(额外 100 个输出 token)。', + 'streaming.highResolutionRecommendation': + '当前是 high 质量大尺寸请求,建议启用流式预览或选择支持 keepalive SSE 的上游。', + 'upstream.backend': '图片生成后端', + 'upstream.backendImages': 'Images API', + 'upstream.backendResponses': 'Responses image_generation', + 'upstream.streamingStrategy': '流式兼容模式', + 'upstream.strategyAuto': '自动', + 'upstream.strategyOff': '关闭', + 'upstream.strategyOpenAiSse': 'OpenAI SSE', + 'upstream.strategyKeepaliveSse': 'new-api/sub2api keepalive SSE', + 'upstream.strategyResponsesSse': 'Responses SSE', + 'upstream.strategyForceSse': '强制 SSE', + 'upstream.serverDefault': '服务端默认', + 'upstream.responsesModel': 'Responses 顶层模型', 'generate.title': '生成图片', 'generate.description': '根据文本提示词创建新图片。', 'generate.submit': '生成', @@ -115,6 +129,7 @@ const messages: Record> = { 'output.generating': '正在生成图片...', 'output.editing': '正在编辑图片...', 'output.streaming': '流式生成中...', + 'output.keepalive': '连接保持中...', 'output.sendToEdit': '发送到编辑', 'output.download': '下载', 'output.share': '分享', @@ -349,6 +364,20 @@ const messages: Record> = { 'Shows partial preview images as they are generated, providing a more interactive experience.', 'streaming.batchDescription': 'Batch streaming is enabled: each image runs as a separate streaming job.', 'streaming.costHint': 'Each preview image adds about $0.003 to the cost (100 additional output tokens).', + 'streaming.highResolutionRecommendation': + 'This is a high quality large image request. Streaming preview or a keepalive SSE upstream is recommended.', + 'upstream.backend': 'Image backend', + 'upstream.backendImages': 'Images API', + 'upstream.backendResponses': 'Responses image_generation', + 'upstream.streamingStrategy': 'Streaming compatibility mode', + 'upstream.strategyAuto': 'Auto', + 'upstream.strategyOff': 'Off', + 'upstream.strategyOpenAiSse': 'OpenAI SSE', + 'upstream.strategyKeepaliveSse': 'new-api/sub2api keepalive SSE', + 'upstream.strategyResponsesSse': 'Responses SSE', + 'upstream.strategyForceSse': 'Force SSE', + 'upstream.serverDefault': 'Server default', + 'upstream.responsesModel': 'Responses top-level model', 'generate.title': 'Generate Image', 'generate.description': 'Create a new image from a text prompt.', 'generate.submit': 'Generate', @@ -389,6 +418,7 @@ const messages: Record> = { 'output.generating': 'Generating image...', 'output.editing': 'Editing image...', 'output.streaming': 'Streaming...', + 'output.keepalive': 'Connection kept alive...', 'output.sendToEdit': 'Send to Edit', 'output.download': 'Download', 'output.share': 'Share', diff --git a/src/lib/image-request-utils.ts b/src/lib/image-request-utils.ts index 45b5d2ab88728c1f4a83d15ad624270dafc254e1..a35fba81fe4b98831d9508c3bc76f68191c08b4b 100644 --- a/src/lib/image-request-utils.ts +++ b/src/lib/image-request-utils.ts @@ -1,3 +1,4 @@ +import { validateGptImage2Size } from './size-utils'; import type OpenAI from 'openai'; export const VALID_IMAGE_FILENAME_PATTERN = /^\d{13}(?:-[a-f0-9]{16})?-\d+\.(png|jpe?g|webp)$/i; @@ -143,6 +144,13 @@ export function readSize(formData: FormData, field: string, fallback: string, mo if (model === 'gpt-image-2' && value !== 'auto' && !/^\d+x\d+$/.test(value)) { throw new RequestValidationError(`${field} 必须是 auto 或 WxH 格式的尺寸值。`); } + if (model === 'gpt-image-2' && value !== 'auto') { + const [width, height] = value.split('x').map(Number); + const validation = validateGptImage2Size(width, height); + if (!validation.valid) { + throw new RequestValidationError(`${field} 对 ${model} 无效:${validation.reason}`); + } + } return value; } diff --git a/src/lib/image-route-mode-handlers.ts b/src/lib/image-route-mode-handlers.ts index cb985d98423dec3ca30dd19fccb87e60ad0c7d86..10b9f2eb4f9408c70101b316ffd44ecccdee5d7a 100644 --- a/src/lib/image-route-mode-handlers.ts +++ b/src/lib/image-route-mode-handlers.ts @@ -21,7 +21,7 @@ import { type ValidOutputFormat } from './image-request-utils'; import { createImageStreamResponse } from './image-stream-service'; -import { generateImageWithResponsesBackend, type ResponsesImageGenerateInput } from './responses-image-backend'; +import { createImagesApiGenerateStream } from './images-api-stream'; import { appendAccessCookie, reportServerCredentialFailure, @@ -30,6 +30,11 @@ import { type ImageBackend, type RequestLogContext } from './image-route-support'; +import { + createResponsesImageStream, + generateImageWithResponsesBackend, + type ResponsesImageGenerateInput +} from './responses-image-backend'; import type OpenAI from 'openai'; type CommonModeInput = { @@ -47,6 +52,7 @@ type CommonModeInput = { requestLogContext?: RequestLogContext; selectedCredential?: ChannelCredential; accessCookie?: AccessCookie; + abortSignal?: AbortSignal; }; export type ImageModeResult = @@ -109,15 +115,16 @@ function readResponsesImageSize(size: string): ResponsesImageGenerateInput['size if (size === 'auto' || size === '1024x1024' || size === '1024x1536' || size === '1536x1024') { return size; } - throw new RequestValidationError('Responses API 图片后端当前只支持 auto、1024x1024、1024x1536 或 1536x1024 尺寸。', 400); + throw new RequestValidationError( + 'Responses API 图片后端当前只支持 auto、1024x1024、1024x1536 或 1536x1024 尺寸。', + 400 + ); } function readResponsesApiModel(formData: FormData): string { const requestValue = formData.get('responsesModel'); const rawValue = - typeof requestValue === 'string' && requestValue.trim() - ? requestValue - : process.env.OPENAI_RESPONSES_API_MODEL; + typeof requestValue === 'string' && requestValue.trim() ? requestValue : process.env.OPENAI_RESPONSES_API_MODEL; const model = rawValue?.trim(); if (!model) { throw new RequestValidationError( @@ -131,10 +138,11 @@ function readResponsesApiModel(formData: FormData): string { return model; } -async function createResponsesImageResult( - input: CommonModeInput, - options: GenerateOptions -): Promise { +function openAiRequestOptions(input: CommonModeInput): OpenAI.RequestOptions | undefined { + return input.abortSignal ? { signal: input.abortSignal } : undefined; +} + +async function createResponsesImageResult(input: CommonModeInput, options: GenerateOptions): Promise { if (options.n !== 1) { throw new RequestValidationError('Responses API 图片后端当前只支持单张生成。', 400); } @@ -151,18 +159,59 @@ async function createResponsesImageResult( outputFormat: options.outputFormat, background: options.background, moderation: options.moderation, + abortSignal: input.abortSignal, ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}) }) }; } +async function createResponsesImageStreamResponse(input: CommonModeInput, options: GenerateOptions): Promise { + if (options.n !== 1) { + throw new RequestValidationError('Responses API 图片后端当前只支持单张生成。', 400); + } + const stream = await createResponsesImageStream({ + responses: input.openai.responses, + prompt: input.prompt, + responsesModel: readResponsesApiModel(input.formData), + imageModel: input.model, + size: readResponsesImageSize(options.size), + quality: options.quality, + outputFormat: options.outputFormat, + background: options.background, + moderation: options.moderation, + partialImagesCount: input.partialImagesCount, + abortSignal: input.abortSignal, + ...(options.outputCompression !== undefined ? { outputCompression: options.outputCompression } : {}) + }); + const response = createImageStreamResponse({ + stream, + modeLabel: '生成', + outputFormat: options.outputFormat, + storageMode: input.storageMode, + apiBaseUrl: input.apiBaseUrl, + apiKey: input.apiKey, + model: input.model, + startedAtMs: input.startedAtMs, + clientRequestId: input.clientRequestId, + requestLogContext: input.requestLogContext, + resolveActualCost: resolveRequestActualCostSafely, + onError: (error) => reportServerCredentialFailure(input.selectedCredential, error) + }); + return appendAccessCookie(response, input.accessCookie); +} + async function createGenerateStreamResponse(input: CommonModeInput, options: GenerateOptions): Promise { const streamParams = { ...options.baseParams, stream: true as const, partial_images: input.partialImagesCount } satisfies OpenAI.Images.ImageGenerateParamsStreaming; - const stream = await input.openai.images.generate(streamParams); + const stream = await createImagesApiGenerateStream({ + apiBaseUrl: input.apiBaseUrl, + apiKey: input.apiKey, + abortSignal: input.abortSignal, + params: streamParams + }); const response = createImageStreamResponse({ stream, modeLabel: '生成', @@ -184,14 +233,20 @@ export async function handleGenerateImageMode( input: CommonModeInput & { imageBackend: ImageBackend } ): Promise { const options = readGenerateOptions(input); - if (input.imageBackend === 'responses') { + if (input.imageBackend === 'responses-image-generation') { + if (input.streamEnabled) { + return createResponsesImageStreamResponse(input, options); + } return createResponsesImageResult(input, options); } if (!input.streamEnabled) { const params: OpenAI.Images.ImageGenerateParamsNonStreaming = { ...options.baseParams, stream: false }; appLogger.info('调用 OpenAI generate。', input.requestLogContext); appLogger.debug('调用 OpenAI generate,参数:', { ...params, ...input.requestLogContext }); - return { outputFormat: options.outputFormat, result: await input.openai.images.generate(params) }; + return { + outputFormat: options.outputFormat, + result: await input.openai.images.generate(params, openAiRequestOptions(input)) + }; } return createGenerateStreamResponse(input, options); } @@ -238,7 +293,7 @@ async function createEditStreamResponse(input: CommonModeInput, options: EditOpt partial_images: input.partialImagesCount, ...(options.maskFile ? { mask: options.maskFile } : {}) }; - const stream = await input.openai.images.edit(streamEditParams); + const stream = await input.openai.images.edit(streamEditParams, openAiRequestOptions(input)); const response = createImageStreamResponse({ stream, modeLabel: '编辑', @@ -265,7 +320,7 @@ export async function handleEditImageMode(input: CommonModeInput): Promise; -export type ImageBackend = 'images' | 'responses'; +export type ImageBackend = ImageGenerationBackend; export type RequestLogContext = { clientRequestId: string }; export function readClientRequestId(formData: FormData): string | undefined { @@ -21,26 +23,16 @@ export function readClientRequestId(formData: FormData): string | undefined { if (typeof value !== 'string') return undefined; const normalized = value.trim(); if (!normalized) return undefined; - return normalized.slice(0, 128); -} - -export function readImageBackend(formData: FormData): ImageBackend { - const value = formData.get('imageBackend'); - if (value === null || value === undefined || value === '' || value === 'images') { - return 'images'; - } - if (value === 'responses') { - return 'responses'; + if (normalized.length > PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH) { + throw new RequestValidationError( + `clientRequestId 长度不能超过 ${PAGE_SSE_CLIENT_REQUEST_ID_MAX_LENGTH} 个字符。` + ); } - throw new RequestValidationError('imageBackend 必须是 images 或 responses。', 400); + return normalized; } -export function assertResponsesImageBackendAllowed(input: { - imageBackend: ImageBackend; - mode: string; - streamEnabled: boolean; -}) { - if (input.imageBackend !== 'responses') return; +export function assertResponsesImageBackendAllowed(input: { imageBackend: ImageBackend; mode: string }) { + if (input.imageBackend !== 'responses-image-generation') return; if (!readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND')) { throw new RequestValidationError( 'Responses API 图片后端仍是实验能力,必须设置 ENABLE_RESPONSES_IMAGE_BACKEND=true 后才能使用。', @@ -50,9 +42,6 @@ export function assertResponsesImageBackendAllowed(input: { if (input.mode !== 'generate') { throw new RequestValidationError('Responses API 图片后端当前只支持 generate 模式。', 400); } - if (input.streamEnabled) { - throw new RequestValidationError('Responses API 图片后端当前不接入现有 Images API 流式预览。', 400); - } } export function reportServerCredentialFailure(credential: ChannelCredential | undefined, error: unknown) { diff --git a/src/lib/image-service.ts b/src/lib/image-service.ts index 550c418732fb00b79d92c063573606e03cea2697..3e0823272bff0d2fb0408adacd306e9c7135f138 100644 --- a/src/lib/image-service.ts +++ b/src/lib/image-service.ts @@ -38,6 +38,7 @@ export class InvalidOpenAiImagesResponseError extends Error { export class MissingOpenAiImageDataError extends Error { readonly index: number; + readonly status = 502; constructor(index: number) { super(`索引 ${index} 的图片数据缺少 base64 数据。`); diff --git a/src/lib/image-stream-collector.test.ts b/src/lib/image-stream-collector.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7dcf89d4e052f0dea70fb29770b0ee258b5fd8a8 --- /dev/null +++ b/src/lib/image-stream-collector.test.ts @@ -0,0 +1,138 @@ +import { collectOpenAiImagesFromStream, MissingFinalImageStreamResultError } from './image-stream-collector'; +import { upstreamEvents } from './sse-test-utils'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +const PNG_BASE64 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII='; + +describe('collectOpenAiImagesFromStream', () => { + it('deduplicates repeated final image payloads from Responses streams', async () => { + const result = await collectOpenAiImagesFromStream( + upstreamEvents([ + { + type: 'response.output_item.done', + item: { id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 } + }, + { + type: 'response.completed', + response: { + output: [{ id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 }] + } + } + ]) + ); + + assert.equal(result.data?.length, 1); + assert.equal(result.data?.[0]?.b64_json, PNG_BASE64); + }); + + it('deduplicates repeated Responses final payloads when the upstream omits image call ids', async () => { + const result = await collectOpenAiImagesFromStream( + upstreamEvents([ + { + type: 'response.output_item.done', + item: { type: 'image_generation_call', status: 'completed', result: PNG_BASE64 } + }, + { + type: 'response.completed', + response: { + output: [{ type: 'image_generation_call', status: 'completed', result: PNG_BASE64 }] + } + } + ]) + ); + + assert.equal(result.data?.length, 1); + assert.equal(result.data?.[0]?.b64_json, PNG_BASE64); + }); + + it('keeps distinct same-length Responses final payloads when ids are omitted', async () => { + const first = 'A'.repeat(1000); + const second = `${first.slice(0, 100)}B${first.slice(101)}`; + const result = await collectOpenAiImagesFromStream( + upstreamEvents([ + { + type: 'response.output_item.done', + item: { type: 'image_generation_call', status: 'completed', result: first } + }, + { + type: 'response.output_item.done', + item: { type: 'image_generation_call', status: 'completed', result: second } + } + ]) + ); + + assert.deepEqual( + result.data?.map((item) => item.b64_json), + [first, second] + ); + }); + + it('keeps separate Responses final items when their base64 payloads match', async () => { + const result = await collectOpenAiImagesFromStream( + upstreamEvents([ + { + type: 'response.output_item.done', + item: { id: 'ig_same_payload_a', type: 'image_generation_call', result: PNG_BASE64 } + }, + { + type: 'response.output_item.done', + item: { id: 'ig_same_payload_b', type: 'image_generation_call', result: PNG_BASE64 } + } + ]) + ); + + assert.equal(result.data?.length, 2); + assert.deepEqual( + result.data?.map((item) => item.b64_json), + [PNG_BASE64, PNG_BASE64] + ); + }); + + it('deduplicates repeated final image payloads within a single Responses event', async () => { + const result = await collectOpenAiImagesFromStream( + upstreamEvents([ + { + type: 'response.completed', + data: [], + response: { + output: [ + { id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 }, + { id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 } + ] + } + } + ]) + ); + + assert.equal(result.data?.length, 1); + assert.equal(result.data?.[0]?.b64_json, PNG_BASE64); + }); + + it('reports missing-final stream diagnostics without exposing partial image data', async () => { + await assert.rejects( + () => + collectOpenAiImagesFromStream( + upstreamEvents([ + { + type: 'image_generation.partial_image', + partial_image_index: 0, + b64_json: 'partial-one' + }, + { + type: 'image_generation.partial_image', + partial_image_index: 1, + b64_json: 'partial-two' + } + ]) + ), + (error) => { + assert.ok(error instanceof MissingFinalImageStreamResultError); + assert.equal(error.upstreamEventType, 'image_generation.partial_image'); + assert.equal(error.partialImageCount, 2); + assert.equal(JSON.stringify(error).includes('partial-one'), false); + return true; + } + ); + }); +}); diff --git a/src/lib/image-stream-collector.ts b/src/lib/image-stream-collector.ts new file mode 100644 index 0000000000000000000000000000000000000000..66c8b91c30d76661462495b2ab2aa10d5bf10d57 --- /dev/null +++ b/src/lib/image-stream-collector.ts @@ -0,0 +1,62 @@ +import { normalizeUpstreamImageStreamEventWithDiagnostics } from './image-stream-events'; +import type OpenAI from 'openai'; + +type ImageUsage = OpenAI.Images.ImagesResponse['usage']; + +export class MissingFinalImageStreamResultError extends Error { + readonly status = 502; + readonly upstreamEventType?: string; + readonly partialImageCount: number; + + constructor(options: { upstreamEventType?: string; partialImageCount: number }) { + super('流式图片响应未返回最终图片 b64_json。'); + this.name = 'MissingFinalImageStreamResultError'; + this.upstreamEventType = options.upstreamEventType; + this.partialImageCount = options.partialImageCount; + } +} + +export async function collectOpenAiImagesFromStream( + stream: AsyncIterable +): Promise { + const data: Array<{ b64_json: string }> = []; + const seenCompletedKeys = new Set(); + let usage: ImageUsage | undefined; + let upstreamEventType: string | undefined; + let partialImageCount = 0; + + for await (const event of stream) { + const diagnostics = normalizeUpstreamImageStreamEventWithDiagnostics(event); + upstreamEventType = diagnostics.upstreamEventType ?? upstreamEventType; + for (const normalizedEvent of diagnostics.events) { + if (normalizedEvent.type === 'partial_image') { + partialImageCount += 1; + } + if (normalizedEvent.type === 'completed') { + if (normalizedEvent.dedupeKey && seenCompletedKeys.has(normalizedEvent.dedupeKey)) { + if (normalizedEvent.usage) { + usage = normalizedEvent.usage; + } + continue; + } + data.push({ b64_json: normalizedEvent.b64Json }); + if (normalizedEvent.dedupeKey) { + seenCompletedKeys.add(normalizedEvent.dedupeKey); + } + if (normalizedEvent.usage) { + usage = normalizedEvent.usage; + } + } + } + } + + if (data.length === 0) { + throw new MissingFinalImageStreamResultError({ upstreamEventType, partialImageCount }); + } + + return { + created: Math.floor(Date.now() / 1000), + data, + ...(usage ? { usage } : {}) + }; +} diff --git a/src/lib/image-stream-events.test.ts b/src/lib/image-stream-events.test.ts index 1deee0d3fa40a575bec1ca818728237520ee0890..0bf58167592420827802aea6e8d128ea57449843 100644 --- a/src/lib/image-stream-events.test.ts +++ b/src/lib/image-stream-events.test.ts @@ -104,6 +104,326 @@ describe('normalizeUpstreamImageStreamEvent', () => { ); }); + it('maps Responses image partial events to stable partial image events', () => { + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.image_generation_call.partial_image', + partial_image_b64: 'responses-partial-base64', + partial_image_index: 0 + }), + [ + { + type: 'partial_image', + b64Json: 'responses-partial-base64', + partialImageIndex: 0 + } + ] + ); + + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.image_generation_call.partial_image', + b64_json: 'responses-partial-b64-json', + partial_image_index: 1 + }), + [ + { + type: 'partial_image', + b64Json: 'responses-partial-b64-json', + partialImageIndex: 1 + } + ] + ); + }); + + it('maps Responses output item image results to completed events', () => { + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + result: 'responses-final-base64' + } + }), + [ + { + type: 'completed', + b64Json: 'responses-final-base64' + } + ] + ); + + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + result: 'data:image/png;base64,responses-data-url-final-base64' + } + }), + [ + { + type: 'completed', + b64Json: 'responses-data-url-final-base64' + } + ] + ); + }); + + it('uses a bounded dedupe key when Responses image call ids are missing', () => { + const payload = 'responses-final-base64'.repeat(128); + const result = normalizeUpstreamImageStreamEventWithDiagnostics({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + result: payload + } + }); + + assert.equal(result.events.length, 1); + const [event] = result.events; + assert.equal(event.type, 'completed'); + if (event.type !== 'completed') return; + assert.match(event.dedupeKey || '', /^responses:result:fingerprint:\d+:[a-f0-9]{64}$/); + assert.equal(event.dedupeKey?.includes(payload), false); + assert.ok((event.dedupeKey || '').length < 256); + }); + + it('keeps distinct bounded dedupe keys when same-length Responses payloads differ by one byte', () => { + const first = 'A'.repeat(1000); + const second = `${first.slice(0, 100)}B${first.slice(101)}`; + const readDedupeKey = (payload: string) => { + const result = normalizeUpstreamImageStreamEventWithDiagnostics({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + result: payload + } + }); + const [event] = result.events; + assert.equal(event.type, 'completed'); + if (event.type !== 'completed') return undefined; + return event.dedupeKey; + }; + + assert.equal(first.length, second.length); + assert.notEqual(readDedupeKey(first), readDedupeKey(second)); + }); + + it('recognizes Responses image generation completed markers without requiring them to carry final images', () => { + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.image_generation_call.completed', + item_id: 'ig_123' + }), + [] + ); + + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.image_generation_call.completed', + item: { + type: 'image_generation_call', + result: 'responses-completed-final-base64' + } + }), + [ + { + type: 'completed', + b64Json: 'responses-completed-final-base64' + } + ] + ); + + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.image_generation_call.completed', + result: 'responses-completed-top-level-final-base64' + }), + [ + { + type: 'completed', + b64Json: 'responses-completed-top-level-final-base64' + } + ] + ); + }); + + it('ignores Responses output item done events when they do not carry image results', () => { + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'message', + content: [{ type: 'output_text', text: 'done' }] + } + }), + [] + ); + }); + + it('fails explicitly when Responses output item done reports a failed image result', () => { + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'failed', + error: { + code: 'content_policy_violation', + message: 'blocked by upstream policy' + } + } + }), + /blocked by upstream policy/ + ); + + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.completed', + response: { + output: [ + { + type: 'image_generation_call', + status: 'failed', + error: { + message: 'responses completed image failed' + } + } + ] + } + }), + /responses completed image failed/ + ); + }); + + it('fails explicitly when Responses output item done lacks a final image result', () => { + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed' + } + }), + /image_generation_call\.result/ + ); + }); + + it('fails explicitly when Responses image results only contain remote URLs', () => { + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + status: 'completed', + result: 'https://example.test/image.png' + } + }), + /远程 URL/ + ); + + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + url: 'https://example.test/image.png' + } + }), + /远程 URL/ + ); + + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.image_generation_call.completed', + url: 'https://example.test/image.png' + }), + /远程 URL/ + ); + + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.completed', + response: { + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: 'https://example.test/image.png' + } + ] + } + }), + /远程 URL/ + ); + }); + + it('maps Responses completed output image results and usage to completed events', () => { + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.completed', + response: { + usage: { input_tokens: 7, output_tokens: 11, total_tokens: 18 }, + output: [ + { + type: 'image_generation_call', + result: 'responses-final-a' + }, + { + type: 'message', + content: [{ type: 'output_text', text: 'done' }] + }, + { + type: 'image_generation_call', + result: 'data:image/png;base64,responses-final-b' + } + ] + } + }), + [ + { + type: 'completed', + b64Json: 'responses-final-a', + usage: { input_tokens: 7, output_tokens: 11, total_tokens: 18 } + }, + { + type: 'completed', + b64Json: 'responses-final-b', + usage: { input_tokens: 7, output_tokens: 11, total_tokens: 18 } + } + ] + ); + + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.completed', + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 }, + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: 'responses-top-level-final' + } + ] + }), + [ + { + type: 'completed', + b64Json: 'responses-top-level-final', + usage: { input_tokens: 3, output_tokens: 5, total_tokens: 8 } + } + ] + ); + }); + it('treats SDK-parsed OtokAPI root image data as a partial chunk when the SSE event name is dropped', () => { assert.deepEqual( normalizeUpstreamImageStreamEvent({ @@ -144,6 +464,56 @@ describe('normalizeUpstreamImageStreamEvent', () => { ); }); + it('fails explicitly when a completed image event only has a remote URL', () => { + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'image_generation.completed', + url: 'https://example.test/image.png' + }), + /b64_json/ + ); + }); + + it('fails explicitly when a Responses stream reports response.failed', () => { + assert.throws( + () => + normalizeUpstreamImageStreamEvent({ + type: 'response.failed', + response: { + error: { + code: 'invalid_prompt', + message: 'blocked by upstream policy' + } + } + }), + /blocked by upstream policy/ + ); + }); + + it('limits Responses image traversal depth for deeply nested payloads', () => { + let payload: Record = { + type: 'image_generation_call', + result: 'deep-final' + }; + for (let index = 0; index < 20000; index += 1) { + payload = { item: payload }; + } + + assert.deepEqual( + normalizeUpstreamImageStreamEvent({ + type: 'response.output_item.done', + item: payload + }), + [] + ); + }); + + it('ignores SSE keepalive comments and non-object events', () => { + assert.deepEqual(normalizeUpstreamImageStreamEvent(':'), []); + assert.deepEqual(normalizeUpstreamImageStreamEvent(null), []); + }); + it('reports provider dialect diagnostics without treating unknown completed-like payloads as success', () => { assert.equal( normalizeUpstreamImageStreamEventWithDiagnostics({ @@ -159,6 +529,16 @@ describe('normalizeUpstreamImageStreamEvent', () => { }).providerDialect, 'otokapi_image_event' ); + assert.equal( + normalizeUpstreamImageStreamEventWithDiagnostics({ + type: 'response.output_item.done', + item: { + type: 'image_generation_call', + result: 'responses-final' + } + }).providerDialect, + 'responses_image_event' + ); assert.equal( normalizeUpstreamImageStreamEventWithDiagnostics({ data: [{ b64_json: 'sdk-final' }] diff --git a/src/lib/image-stream-events.ts b/src/lib/image-stream-events.ts index 657509b9f8ff10ccef6cfa68b4363699d3ea9a5f..bb9fe9d7c921cd5b84489f5c1a5bf50155b5ba4b 100644 --- a/src/lib/image-stream-events.ts +++ b/src/lib/image-stream-events.ts @@ -1,10 +1,12 @@ import { asRecord, type JsonRecord } from './json-record'; +import { createHash } from 'node:crypto'; import type OpenAI from 'openai'; type ImageUsage = OpenAI.Images.ImagesResponse['usage']; export type ImageStreamProviderDialect = | 'official_image_event' + | 'responses_image_event' | 'otokapi_image_event' | 'sdk_parsed_fallback' | 'unknown_ignored_event'; @@ -19,6 +21,7 @@ export type NormalizedImageStreamEvent = type: 'completed'; b64Json: string; usage?: ImageUsage; + dedupeKey?: string; }; export type ImageStreamEventNormalizationResult = { @@ -27,16 +30,29 @@ export type ImageStreamEventNormalizationResult = { upstreamEventType?: string; }; +class UpstreamImageStreamEventError extends Error { + readonly status = 502; + + constructor(message: string) { + super(message); + this.name = 'UpstreamImageStreamEventError'; + } +} + const PARTIAL_EVENT_TYPES = new Set([ 'image_generation.partial_image', 'image_edit.partial_image', - 'image.generation.chunk' + 'image.generation.chunk', + 'response.image_generation_call.partial_image' ]); const COMPLETED_EVENT_TYPES = new Set([ 'image_generation.completed', 'image_edit.completed', - 'image.generation.result' + 'image.generation.result', + 'response.image_generation_call.completed', + 'response.output_item.done', + 'response.completed' ]); const OFFICIAL_EVENT_TYPES = new Set([ @@ -46,7 +62,17 @@ const OFFICIAL_EVENT_TYPES = new Set([ 'image_edit.completed' ]); +const RESPONSES_EVENT_TYPES = new Set([ + 'response.image_generation_call.partial_image', + 'response.image_generation_call.completed', + 'response.output_item.done', + 'response.completed', + 'response.failed', + 'error' +]); + const OTOKAPI_EVENT_TYPES = new Set(['image.generation.chunk', 'image.generation.result']); +const MAX_RESPONSES_IMAGE_TRAVERSAL_DEPTH = 64; function readString(record: JsonRecord, ...keys: string[]): string | undefined { for (const key of keys) { @@ -88,6 +114,23 @@ function extractBase64FromDataUrl(value: string | undefined): string | undefined return payload || undefined; } +function isRemoteHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function readImageGenerationResultBase64(value: string | undefined): string | undefined { + if (!value) return undefined; + const dataUrlPayload = extractBase64FromDataUrl(value); + if (dataUrlPayload) return dataUrlPayload; + if (isRemoteHttpUrl(value)) return undefined; + return value; +} + function readImageBase64(record: JsonRecord): string | undefined { return ( readString(record, 'b64_json', 'b64Json', 'partial_image_b64', 'partialImageB64') || @@ -122,6 +165,47 @@ function readFirstNestedUsage(record: JsonRecord): ImageUsage | undefined { return undefined; } +function readResponseUsage(record: JsonRecord): ImageUsage | undefined { + const response = asRecord(record.response); + return response ? readUsage(response) : undefined; +} + +function readErrorMessage(record: JsonRecord): string | undefined { + const direct = readString(record, 'message', 'error_description', 'error'); + if (direct) { + return direct; + } + const error = asRecord(record.error); + return error ? readString(error, 'message', 'code') : undefined; +} + +function readResponsesFailureMessage(record: JsonRecord): string { + const response = asRecord(record.response); + const message = (response ? readErrorMessage(response) : undefined) || readErrorMessage(record); + return message || '上游未提供错误信息。'; +} + +function readFailedResponsesImageMessage(record: JsonRecord): string | undefined { + for (const item of readResponsesImageGenerationItems(record)) { + if (readString(item, 'status') === 'failed') { + return readErrorMessage(item) || '上游未提供错误信息。'; + } + } + return undefined; +} + +function assertNotExplicitFailureEvent(record: JsonRecord, eventType: string | undefined) { + if (eventType === 'response.failed' || eventType === 'error') { + throw new UpstreamImageStreamEventError(`Responses API 流式响应失败:${readResponsesFailureMessage(record)}`); + } + if (eventType && RESPONSES_EVENT_TYPES.has(eventType)) { + const failedImageMessage = readFailedResponsesImageMessage(record); + if (failedImageMessage) { + throw new UpstreamImageStreamEventError(`Responses API image_generation_call 失败:${failedImageMessage}`); + } + } +} + function readNestedCompletedItems(record: JsonRecord): JsonRecord[] { const items: JsonRecord[] = []; const visit = (current: JsonRecord) => { @@ -134,8 +218,99 @@ function readNestedCompletedItems(record: JsonRecord): JsonRecord[] { return items; } +function readOutputItems(record: JsonRecord): JsonRecord[] { + const { output } = record; + if (!Array.isArray(output)) { + return []; + } + return output.flatMap((item) => { + const itemRecord = asRecord(item); + return itemRecord ? [itemRecord] : []; + }); +} + +function readResponsesImageGenerationItems(record: JsonRecord): JsonRecord[] { + const items: JsonRecord[] = []; + const visit = (current: JsonRecord, depth: number) => { + if (depth > MAX_RESPONSES_IMAGE_TRAVERSAL_DEPTH) { + return; + } + const currentType = readString(current, 'type'); + if (currentType === 'image_generation_call') { + items.push(current); + } + if ( + currentType === 'response.image_generation_call.completed' && + (readString(current, 'result', 'url') || readImageBase64(current)) + ) { + items.push({ ...current, type: 'image_generation_call' }); + } + + const item = asRecord(current.item); + if (item) { + visit(item, depth + 1); + } + + const response = asRecord(current.response); + if (response) { + visit(response, depth + 1); + } + + for (const outputItem of readOutputItems(current)) { + visit(outputItem, depth + 1); + } + }; + visit(record, 0); + return items; +} + +function readResponsesImageBase64(record: JsonRecord): string | undefined { + if (readString(record, 'type') !== 'image_generation_call') { + return undefined; + } + const result = readString(record, 'result'); + return readImageGenerationResultBase64(result) || readImageBase64(record); +} + +function readResponsesImageDedupeKey(record: JsonRecord, b64Json?: string): string | undefined { + if (readString(record, 'type') !== 'image_generation_call') { + return undefined; + } + const id = readString(record, 'id', 'item_id', 'itemId', 'call_id', 'callId'); + if (id) return `responses:${id}`; + return b64Json ? `responses:result:${fingerprintImagePayload(b64Json)}` : undefined; +} + +function fingerprintImagePayload(value: string): string { + return `fingerprint:${value.length}:${hashImagePayload(value)}`; +} + +function hashImagePayload(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +function hasRemoteOnlyResponsesImageResult(record: JsonRecord): boolean { + return readResponsesImageGenerationItems(record).some((item) => { + const result = readString(item, 'result'); + const url = readString(item, 'url'); + return Boolean( + ((result && isRemoteHttpUrl(result)) || (url && isRemoteHttpUrl(url))) && !readImageBase64(item) + ); + }); +} + +function hasCompletedResponsesImageItem(record: JsonRecord): boolean { + return readResponsesImageGenerationItems(record).some((item) => { + const status = readString(item, 'status'); + return status === 'completed' || Boolean(readString(item, 'result', 'url')); + }); +} + function hasCompletedImagePayload(record: JsonRecord): boolean { - return readNestedCompletedItems(record).some((item) => Boolean(readImageBase64(item))); + return ( + readNestedCompletedItems(record).some((item) => Boolean(readImageBase64(item))) || + readResponsesImageGenerationItems(record).some((item) => Boolean(readResponsesImageBase64(item))) + ); } function normalizePartialEvent(record: JsonRecord): NormalizedImageStreamEvent[] { @@ -153,25 +328,73 @@ function normalizePartialEvent(record: JsonRecord): NormalizedImageStreamEvent[] ]; } +type CompletedImageSource = { + b64Json: string; + dedupeKey?: string; +}; + +function appendCompletedSource(completed: CompletedImageSource[], sourceValues: CompletedImageSource[]) { + const previousKeys = new Set(completed.flatMap((item) => (item.dedupeKey ? [item.dedupeKey] : []))); + for (const value of sourceValues) { + if (value.dedupeKey && previousKeys.has(value.dedupeKey)) { + continue; + } + completed.push(value); + if (value.dedupeKey) previousKeys.add(value.dedupeKey); + } +} + function normalizeCompletedEvent(record: JsonRecord, eventType: string | undefined): NormalizedImageStreamEvent[] { - const usage = readUsage(record) || readFirstNestedUsage(record); + const usage = readUsage(record) || readResponseUsage(record) || readFirstNestedUsage(record); const rootB64 = readImageBase64(record); const dataItems = readNestedCompletedItems(record); - const completed = [ - ...(rootB64 ? [rootB64] : []), - ...dataItems.flatMap((item) => { + const responsesItems = readResponsesImageGenerationItems(record); + const completed: CompletedImageSource[] = []; + appendCompletedSource(completed, rootB64 ? [{ b64Json: rootB64 }] : []); + appendCompletedSource( + completed, + dataItems.flatMap((item) => { const b64Json = readImageBase64(item); - return b64Json ? [b64Json] : []; + if (!b64Json) return []; + const dedupeKey = readResponsesImageDedupeKey(item, b64Json); + return [{ b64Json, ...(dedupeKey ? { dedupeKey } : {}) }]; }) - ]; + ); + appendCompletedSource( + completed, + responsesItems.flatMap((item) => { + const b64Json = readResponsesImageBase64(item); + if (!b64Json) return []; + const dedupeKey = readResponsesImageDedupeKey(item, b64Json); + return [{ b64Json, ...(dedupeKey ? { dedupeKey } : {}) }]; + }) + ); - if (completed.length === 0 && eventType && COMPLETED_EVENT_TYPES.has(eventType)) { - throw new Error(`流式图片完成事件缺少 b64_json。上游事件类型:${eventType}。`); + if ( + completed.length === 0 && + eventType && + COMPLETED_EVENT_TYPES.has(eventType) && + !RESPONSES_EVENT_TYPES.has(eventType) + ) { + throw new UpstreamImageStreamEventError(`流式图片完成事件缺少 b64_json。上游事件类型:${eventType}。`); + } + if (completed.length === 0 && eventType && RESPONSES_EVENT_TYPES.has(eventType)) { + if (hasRemoteOnlyResponsesImageResult(record)) { + throw new UpstreamImageStreamEventError( + `Responses API 图片完成事件只返回远程 URL,缺少可保存的 base64 result。上游事件类型:${eventType}。` + ); + } + if (hasCompletedResponsesImageItem(record)) { + throw new UpstreamImageStreamEventError( + `Responses API 图片完成事件缺少 image_generation_call.result。上游事件类型:${eventType}。` + ); + } } - return completed.map((b64Json) => ({ + return completed.map((item) => ({ type: 'completed' as const, - b64Json, + b64Json: item.b64Json, + ...(item.dedupeKey ? { dedupeKey: item.dedupeKey } : {}), ...(usage ? { usage } : {}) })); } @@ -183,6 +406,9 @@ function classifyProviderDialect( if (eventType && OFFICIAL_EVENT_TYPES.has(eventType)) { return 'official_image_event'; } + if (eventType && RESPONSES_EVENT_TYPES.has(eventType)) { + return 'responses_image_event'; + } if (eventType && OTOKAPI_EVENT_TYPES.has(eventType)) { return 'otokapi_image_event'; } @@ -199,12 +425,16 @@ export function normalizeUpstreamImageStreamEventWithDiagnostics(event: unknown) } const eventType = readString(record, 'type'); + assertNotExplicitFailureEvent(record, eventType); let events: NormalizedImageStreamEvent[]; if (eventType && PARTIAL_EVENT_TYPES.has(eventType)) { events = normalizePartialEvent(record); } else if (!eventType && readImageBase64(record)) { events = normalizePartialEvent(record); - } else if ((eventType && COMPLETED_EVENT_TYPES.has(eventType)) || (!eventType && hasCompletedImagePayload(record))) { + } else if ( + (eventType && COMPLETED_EVENT_TYPES.has(eventType)) || + (!eventType && hasCompletedImagePayload(record)) + ) { events = normalizeCompletedEvent(record, eventType); } else { events = []; @@ -219,5 +449,14 @@ export function normalizeUpstreamImageStreamEventWithDiagnostics(event: unknown) } export function normalizeUpstreamImageStreamEvent(event: unknown): NormalizedImageStreamEvent[] { - return normalizeUpstreamImageStreamEventWithDiagnostics(event).events; + return normalizeUpstreamImageStreamEventWithDiagnostics(event).events.map((normalizedEvent) => { + if (normalizedEvent.type !== 'completed' || !normalizedEvent.dedupeKey) { + return normalizedEvent; + } + return { + type: 'completed', + b64Json: normalizedEvent.b64Json, + ...(normalizedEvent.usage ? { usage: normalizedEvent.usage } : {}) + }; + }); } diff --git a/src/lib/image-stream-service.test.ts b/src/lib/image-stream-service.test.ts index 8c238e2f05bb281a723b053f82fd952b08b22465..557808eef945011f847dc6c5bf7a500835e08d76 100644 --- a/src/lib/image-stream-service.test.ts +++ b/src/lib/image-stream-service.test.ts @@ -112,4 +112,98 @@ describe('createImageStreamResponse', () => { assert.match(logText, /"providerDialect": "unknown_ignored_event"/); assert.doesNotMatch(logText, new RegExp(PNG_BASE64)); }); + + it('deduplicates repeated Responses final image items by item id', async () => { + const response = createImageStreamResponse({ + stream: upstreamEvents([ + { + type: 'response.output_item.done', + item: { id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 } + }, + { + type: 'response.completed', + response: { + output: [{ id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 }] + } + } + ]), + modeLabel: '生成', + outputFormat: 'png', + storageMode: 'indexeddb', + apiKey: 'test-key', + model: 'gpt-image-2', + startedAtMs: 1000, + resolveActualCost + }); + + const events = await readSseEvents(response); + + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal((events[1].images as Array>).length, 1); + }); + + it('keeps separate Responses final items when their base64 payloads match', async () => { + const response = createImageStreamResponse({ + stream: upstreamEvents([ + { + type: 'response.output_item.done', + item: { id: 'ig_same_payload_a', type: 'image_generation_call', result: PNG_BASE64 } + }, + { + type: 'response.output_item.done', + item: { id: 'ig_same_payload_b', type: 'image_generation_call', result: PNG_BASE64 } + } + ]), + modeLabel: '生成', + outputFormat: 'png', + storageMode: 'indexeddb', + apiKey: 'test-key', + model: 'gpt-image-2', + startedAtMs: 1000, + resolveActualCost + }); + + const events = await readSseEvents(response); + + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'completed', 'done'] + ); + assert.equal((events[2].images as Array>).length, 2); + }); + + it('deduplicates repeated Responses final image items within a single event', async () => { + const response = createImageStreamResponse({ + stream: upstreamEvents([ + { + type: 'response.completed', + data: [], + response: { + output: [ + { id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 }, + { id: 'ig_repeat', type: 'image_generation_call', result: PNG_BASE64 } + ] + } + } + ]), + modeLabel: '生成', + outputFormat: 'png', + storageMode: 'indexeddb', + apiKey: 'test-key', + model: 'gpt-image-2', + startedAtMs: 1000, + resolveActualCost + }); + + const events = await readSseEvents(response); + + assert.deepEqual( + events.map((event) => event.type), + ['completed', 'done'] + ); + assert.equal((events[1].images as Array>).length, 1); + }); }); diff --git a/src/lib/image-stream-service.ts b/src/lib/image-stream-service.ts index ca4024967bbb435d667b63d567af17360ce7c583..7194d081ad110ff536920790ea12c7aaa825edb6 100644 --- a/src/lib/image-stream-service.ts +++ b/src/lib/image-stream-service.ts @@ -63,6 +63,7 @@ export type ImageStreamResponseOptions = { type StreamState = { completedImages: CompletedImage[]; + completedImageDedupeKeys: Set; finalUsage?: ImageUsage; imageIndex: number; }; @@ -217,6 +218,21 @@ async function consumeUpstreamStream(runtime: StreamRuntime): Promise { normalizedEventCount: diagnostics.events.length }); for (const normalizedEvent of diagnostics.events) { + if (normalizedEvent.type === 'completed') { + if (!normalizedEvent.dedupeKey) { + if (!(await emitNormalizedEvent(runtime, normalizedEvent))) return false; + continue; + } + if (!runtime.state.completedImageDedupeKeys.has(normalizedEvent.dedupeKey)) { + if (!(await emitNormalizedEvent(runtime, normalizedEvent))) return false; + runtime.state.completedImageDedupeKeys.add(normalizedEvent.dedupeKey); + continue; + } + if (normalizedEvent.usage) { + runtime.state.finalUsage = normalizedEvent.usage; + } + continue; + } if (!(await emitNormalizedEvent(runtime, normalizedEvent))) return false; } } @@ -272,7 +288,7 @@ export function createImageStreamResponse(options: ImageStreamResponseOptions): options, batchId, sse, - state: { completedImages: [], imageIndex: 0 } + state: { completedImages: [], completedImageDedupeKeys: new Set(), imageIndex: 0 } }; try { if (!(await consumeUpstreamStream(runtime))) return; diff --git a/src/lib/image-upstream-form.test.ts b/src/lib/image-upstream-form.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..33bf5c0fdf28e73943aa40263fcadb776e904f6a --- /dev/null +++ b/src/lib/image-upstream-form.test.ts @@ -0,0 +1,42 @@ +import { + IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, + appendImageUpstreamOverrideFields +} from './image-upstream-form'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +function readFormEntries(formData: FormData): Record { + return Object.fromEntries( + Array.from(formData.entries()).map(([key, value]) => [key, String(value)]) + ); +} + +describe('appendImageUpstreamOverrideFields', () => { + it('preserves server upstream defaults when the page form uses server-default controls', () => { + const formData = new FormData(); + + appendImageUpstreamOverrideFields(formData, { + imageBackend: IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, + streamingStrategy: IMAGE_UPSTREAM_FORM_SERVER_DEFAULT, + responsesModel: 'gpt-5.4' + }); + + assert.deepEqual(readFormEntries(formData), {}); + }); + + it('appends explicit page upstream overrides and trims the Responses model', () => { + const formData = new FormData(); + + appendImageUpstreamOverrideFields(formData, { + imageBackend: 'responses-image-generation', + streamingStrategy: 'responses-sse', + responsesModel: ' gpt-5.4 ' + }); + + assert.deepEqual(readFormEntries(formData), { + image_backend: 'responses-image-generation', + image_streaming_strategy: 'responses-sse', + responsesModel: 'gpt-5.4' + }); + }); +}); diff --git a/src/lib/image-upstream-form.ts b/src/lib/image-upstream-form.ts new file mode 100644 index 0000000000000000000000000000000000000000..d148311e0f9461422233ef29e03cf4c1485e1f1d --- /dev/null +++ b/src/lib/image-upstream-form.ts @@ -0,0 +1,28 @@ +import type { ImageGenerationBackend, ImageStreamingStrategy } from './image-upstream-strategy'; + +export const IMAGE_UPSTREAM_FORM_SERVER_DEFAULT = 'server-default'; + +export type ImageUpstreamFormBackend = ImageGenerationBackend | typeof IMAGE_UPSTREAM_FORM_SERVER_DEFAULT; +export type ImageUpstreamFormStreamingStrategy = + | ImageStreamingStrategy + | typeof IMAGE_UPSTREAM_FORM_SERVER_DEFAULT; + +export function appendImageUpstreamOverrideFields( + formData: FormData, + input: { + imageBackend: ImageUpstreamFormBackend; + streamingStrategy: ImageUpstreamFormStreamingStrategy; + responsesModel: string; + } +): void { + if (input.imageBackend !== IMAGE_UPSTREAM_FORM_SERVER_DEFAULT) { + formData.append('image_backend', input.imageBackend); + } + if (input.streamingStrategy !== IMAGE_UPSTREAM_FORM_SERVER_DEFAULT) { + formData.append('image_streaming_strategy', input.streamingStrategy); + } + const responsesModel = input.responsesModel.trim(); + if (input.imageBackend === 'responses-image-generation' && responsesModel) { + formData.append('responsesModel', responsesModel); + } +} diff --git a/src/lib/image-upstream-strategy.test.ts b/src/lib/image-upstream-strategy.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a232f6eebfc397e9c07138c564a9d6bdfd4e5277 --- /dev/null +++ b/src/lib/image-upstream-strategy.test.ts @@ -0,0 +1,194 @@ +import { + readImageGenerationBackend, + readImageStreamingStrategy, + resolveImageStreamEnabled, + shouldRecommendImageStreaming +} from './image-upstream-strategy'; +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +function formData(values: Record = {}): FormData { + const data = new FormData(); + for (const [key, value] of Object.entries(values)) { + data.append(key, value); + } + return data; +} + +function readErrorStatus(error: unknown): number | undefined { + if (typeof error === 'object' && error !== null && 'status' in error && typeof error.status === 'number') { + return error.status; + } + return undefined; +} + +describe('image upstream strategy', () => { + it('uses Images API and auto streaming strategy by default', () => { + assert.equal(readImageGenerationBackend(formData()), 'images-api'); + assert.equal(readImageStreamingStrategy(formData(), {}), 'auto'); + assert.equal( + resolveImageStreamEnabled({ + imageBackend: 'images-api', + requestedStream: false, + streamingStrategy: 'auto' + }), + false + ); + }); + + it('accepts legacy and canonical backend names', () => { + assert.equal(readImageGenerationBackend(formData({ imageBackend: 'images' })), 'images-api'); + assert.equal(readImageGenerationBackend(formData({ image_backend: 'images-api' })), 'images-api'); + assert.equal(readImageGenerationBackend(formData({ imageBackend: 'responses' })), 'responses-image-generation'); + assert.equal( + readImageGenerationBackend(formData({ image_backend: 'responses-image-generation' })), + 'responses-image-generation' + ); + }); + + it('uses IMAGE_GENERATION_BACKEND as the default backend when the request omits it', () => { + assert.equal( + readImageGenerationBackend(formData(), { IMAGE_GENERATION_BACKEND: 'responses' }), + 'responses-image-generation' + ); + assert.equal(readImageGenerationBackend(formData(), { IMAGE_GENERATION_BACKEND: 'images-api' }), 'images-api'); + }); + + it('lets request backend override IMAGE_GENERATION_BACKEND', () => { + assert.equal( + readImageGenerationBackend(formData({ imageBackend: 'images' }), { + IMAGE_GENERATION_BACKEND: 'responses-image-generation' + }), + 'images-api' + ); + }); + + it('lets request streaming strategy override env strategy', () => { + assert.equal( + readImageStreamingStrategy(formData({ imageStreamingStrategy: 'newapi-keepalive-sse' }), { + IMAGE_STREAMING_STRATEGY: 'off' + }), + 'newapi-keepalive-sse' + ); + }); + + it('reports invalid env defaults as server configuration errors', () => { + assert.throws( + () => readImageGenerationBackend(formData(), { IMAGE_GENERATION_BACKEND: 'not-a-backend' }), + (error) => error instanceof Error && readErrorStatus(error) === 500 && /IMAGE_GENERATION_BACKEND/.test(error.message) + ); + assert.throws( + () => readImageStreamingStrategy(formData(), { IMAGE_STREAMING_STRATEGY: 'not-a-strategy' }), + (error) => error instanceof Error && readErrorStatus(error) === 500 && /IMAGE_STREAMING_STRATEGY/.test(error.message) + ); + }); + + it('keeps explicit page stream requests enabled under auto strategy', () => { + assert.equal( + resolveImageStreamEnabled({ + imageBackend: 'images-api', + requestedStream: true, + streamingStrategy: 'auto' + }), + true + ); + }); + + it('rejects explicit stream requests when streaming is off', () => { + assert.throws( + () => + resolveImageStreamEnabled({ + imageBackend: 'images-api', + requestedStream: true, + streamingStrategy: 'off' + }), + /已关闭/ + ); + }); + + it('rejects incompatible backend and streaming strategy combinations', () => { + assert.throws( + () => + resolveImageStreamEnabled({ + imageBackend: 'images-api', + requestedStream: true, + streamingStrategy: 'responses-sse' + }), + /Images API/ + ); + + assert.throws( + () => + resolveImageStreamEnabled({ + imageBackend: 'responses-image-generation', + requestedStream: true, + streamingStrategy: 'openai-sse' + }), + /Responses image_generation/ + ); + }); + + it('supports explicit force-sse without the legacy stream field', () => { + assert.equal( + resolveImageStreamEnabled({ + imageBackend: 'images-api', + requestedStream: false, + streamingStrategy: 'force-sse' + }), + true + ); + }); + + it('recommends streaming only for high quality large auto strategy requests', () => { + assert.equal( + shouldRecommendImageStreaming({ + streamingStrategy: 'auto', + quality: 'high', + width: 3072, + height: 2048, + streamEnabled: false + }), + true + ); + assert.equal( + shouldRecommendImageStreaming({ + streamingStrategy: 'auto', + quality: 'high', + width: 2048, + height: 2049, + streamEnabled: false + }), + true + ); + assert.equal( + shouldRecommendImageStreaming({ + streamingStrategy: 'auto', + quality: 'high', + width: 2048, + height: 2048, + streamEnabled: false + }), + false + ); + assert.equal( + shouldRecommendImageStreaming({ + streamingStrategy: 'newapi-keepalive-sse', + quality: 'high', + width: 3072, + height: 2048, + streamEnabled: false + }), + false + ); + assert.equal( + shouldRecommendImageStreaming({ + streamingStrategy: 'auto', + quality: 'high', + width: 3072, + height: 2048, + streamEnabled: true + }), + false + ); + }); +}); diff --git a/src/lib/image-upstream-strategy.ts b/src/lib/image-upstream-strategy.ts new file mode 100644 index 0000000000000000000000000000000000000000..dcf3b032befceccd5ef42256dd4091570b8f3552 --- /dev/null +++ b/src/lib/image-upstream-strategy.ts @@ -0,0 +1,161 @@ +import { RequestValidationError } from './image-request-utils'; + +export type ImageGenerationBackend = 'images-api' | 'responses-image-generation'; + +export type ImageStreamingStrategy = + | 'off' + | 'auto' + | 'openai-sse' + | 'newapi-keepalive-sse' + | 'responses-sse' + | 'force-sse'; + +type ImageUpstreamEnv = Partial; + +const IMAGE_BACKEND_ALIASES: Record = { + images: 'images-api', + 'images-api': 'images-api', + responses: 'responses-image-generation', + 'responses-image-generation': 'responses-image-generation' +}; + +const VALID_STREAMING_STRATEGIES = new Set([ + 'off', + 'auto', + 'openai-sse', + 'newapi-keepalive-sse', + 'responses-sse', + 'force-sse' +]); + +export class ImageUpstreamConfigurationError extends Error { + readonly status = 500; + + constructor(message: string) { + super(message); + this.name = 'ImageUpstreamConfigurationError'; + } +} + +function readStringField(formData: FormData, ...fields: string[]): string | undefined { + for (const field of fields) { + const value = formData.get(field); + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return undefined; +} + +function readStringEnv(env: ImageUpstreamEnv, key: 'IMAGE_GENERATION_BACKEND' | 'IMAGE_STREAMING_STRATEGY'): string | undefined { + const value = env[key]; + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + return undefined; +} + +function readBackendValue(value: string, source: 'request' | 'env'): ImageGenerationBackend { + const backend = IMAGE_BACKEND_ALIASES[value]; + if (backend) { + return backend; + } + const message = 'imageBackend 必须是 images-api、responses-image-generation、images 或 responses。'; + if (source === 'env') { + throw new ImageUpstreamConfigurationError(`服务端 IMAGE_GENERATION_BACKEND 配置无效:${message}`); + } + throw new RequestValidationError(message, 400); +} + +export function parseImageGenerationBackendValue(value: string): ImageGenerationBackend { + return readBackendValue(value.trim(), 'request'); +} + +function readStreamingStrategyValue(value: string, source: 'request' | 'env'): ImageStreamingStrategy { + if (VALID_STREAMING_STRATEGIES.has(value as ImageStreamingStrategy)) { + return value as ImageStreamingStrategy; + } + const message = '图片流式兼容模式必须是 off、auto、openai-sse、newapi-keepalive-sse、responses-sse 或 force-sse。'; + if (source === 'env') { + throw new ImageUpstreamConfigurationError(`服务端 IMAGE_STREAMING_STRATEGY 配置无效:${message}`); + } + throw new RequestValidationError(message, 400); +} + +export function parseImageStreamingStrategyValue(value: string): ImageStreamingStrategy { + return readStreamingStrategyValue(value.trim(), 'request'); +} + +export function readImageGenerationBackend( + formData: FormData, + env: ImageUpstreamEnv = process.env, + options: { useEnvDefault?: boolean } = {} +): ImageGenerationBackend { + const requestValue = readStringField(formData, 'image_backend', 'imageBackend'); + if (requestValue) { + return readBackendValue(requestValue, 'request'); + } + if (options.useEnvDefault === false) { + return 'images-api'; + } + const envValue = readStringEnv(env, 'IMAGE_GENERATION_BACKEND'); + return envValue ? readBackendValue(envValue, 'env') : 'images-api'; +} + +export function readImageStreamingStrategy( + formData: FormData, + env: ImageUpstreamEnv = process.env, + options: { useEnvDefault?: boolean } = {} +): ImageStreamingStrategy { + const requestValue = readStringField(formData, 'image_streaming_strategy', 'imageStreamingStrategy'); + if (requestValue) { + return readStreamingStrategyValue(requestValue, 'request'); + } + if (options.useEnvDefault === false) { + return 'auto'; + } + const envValue = readStringEnv(env, 'IMAGE_STREAMING_STRATEGY'); + return envValue ? readStreamingStrategyValue(envValue, 'env') : 'auto'; +} + +export function resolveImageStreamEnabled(input: { + imageBackend: ImageGenerationBackend; + requestedStream: boolean; + streamingStrategy: ImageStreamingStrategy; +}): boolean { + if (input.streamingStrategy === 'off') { + if (input.requestedStream) { + throw new RequestValidationError('当前图片流式兼容模式已关闭,不能发送 stream 请求。', 400); + } + return false; + } + + const streamEnabled = input.requestedStream || input.streamingStrategy === 'force-sse'; + if (!streamEnabled) return false; + + if (input.imageBackend === 'images-api' && input.streamingStrategy === 'responses-sse') { + throw new RequestValidationError('Images API 后端不能使用 responses-sse 流式兼容模式。', 400); + } + + if ( + input.imageBackend === 'responses-image-generation' && + (input.streamingStrategy === 'openai-sse' || input.streamingStrategy === 'newapi-keepalive-sse') + ) { + throw new RequestValidationError('Responses image_generation 后端不能使用 Images API SSE 流式兼容模式。', 400); + } + + return true; +} + +export function shouldRecommendImageStreaming(input: { + streamingStrategy: ImageStreamingStrategy; + quality: 'low' | 'medium' | 'high' | 'auto'; + width: number; + height: number; + streamEnabled: boolean; +}): boolean { + if (input.streamEnabled || input.streamingStrategy !== 'auto' || input.quality !== 'high') { + return false; + } + return Math.max(input.width, input.height) > 2048; +} diff --git a/src/lib/images-api-stream.ts b/src/lib/images-api-stream.ts new file mode 100644 index 0000000000000000000000000000000000000000..70eafae8acd77c3e194665263797424366ab6bab --- /dev/null +++ b/src/lib/images-api-stream.ts @@ -0,0 +1,114 @@ +import type OpenAI from 'openai'; + +export class ImagesApiStreamError extends Error { + readonly status: number; + + constructor(message: string, status: number) { + super(message); + this.name = 'ImagesApiStreamError'; + this.status = status; + } +} + +type ImagesApiStreamInput = { + apiBaseUrl?: string; + apiKey: string; + abortSignal?: AbortSignal; + params: OpenAI.Images.ImageGenerateParamsStreaming; +}; + +const DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'; + +function buildImagesGenerateUrl(apiBaseUrl: string | undefined): string { + const normalizedBase = (apiBaseUrl || DEFAULT_OPENAI_BASE_URL).replace(/\/+$/, ''); + return `${normalizedBase}/images/generations`; +} + +function readErrorMessage(body: unknown): string | undefined { + if (!body || typeof body !== 'object') return undefined; + const record = body as Record; + const error = record.error; + if (error && typeof error === 'object') { + const message = (error as Record).message; + if (typeof message === 'string' && message.trim()) return message.trim(); + } + const message = record.message; + return typeof message === 'string' && message.trim() ? message.trim() : undefined; +} + +async function readResponseError(response: Response): Promise { + const contentType = response.headers.get('content-type') || ''; + const text = await response.text(); + let message = text.trim(); + if (contentType.includes('application/json') && text.trim()) { + try { + message = readErrorMessage(JSON.parse(text)) || message; + } catch { + // Keep the raw text when an upstream mislabeled a non-JSON error. + } + } + return new ImagesApiStreamError(message || `Images API stream request failed with HTTP ${response.status}.`, response.status); +} + +function parseSseDataPayload(raw: string): unknown | undefined { + const normalized = raw.trim(); + if (!normalized || normalized === '[DONE]') return undefined; + return JSON.parse(normalized); +} + +async function* readEventStream(response: Response): AsyncIterable { + if (!response.body) return; + const reader = response.body.pipeThrough(new TextDecoderStream()).getReader(); + let buffer = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + const parts = buffer.split(/\r?\n\r?\n/); + buffer = parts.pop() || ''; + for (const part of parts) { + const payload = readSseChunk(part); + if (payload !== undefined) yield payload; + } + } + const payload = readSseChunk(buffer); + if (payload !== undefined) yield payload; + } finally { + reader.releaseLock(); + } +} + +function readSseChunk(chunk: string): unknown | undefined { + if (!chunk.trim()) return undefined; + const dataLines = chunk + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice('data:'.length).trimStart()); + if (dataLines.length === 0) return undefined; + return parseSseDataPayload(dataLines.join('\n')); +} + +export async function createImagesApiGenerateStream(input: ImagesApiStreamInput): Promise> { + const response = await fetch(buildImagesGenerateUrl(input.apiBaseUrl), { + method: 'POST', + headers: { + Authorization: `Bearer ${input.apiKey}`, + 'Content-Type': 'application/json', + Accept: 'text/event-stream, application/json' + }, + signal: input.abortSignal, + body: JSON.stringify(input.params) + }); + if (!response.ok) { + throw await readResponseError(response); + } + const contentType = response.headers.get('content-type') || ''; + if (contentType.includes('text/event-stream')) { + return readEventStream(response); + } + const body = await response.json(); + return (async function* imagesJsonAsFinalEvent() { + yield body; + })(); +} diff --git a/src/lib/log-filter.ts b/src/lib/log-filter.ts index 49ab7603ecabedb64509289da943602a5404044b..ff2f8e1b604b7aceaf28532cf49cf5d15f631d34 100644 --- a/src/lib/log-filter.ts +++ b/src/lib/log-filter.ts @@ -2,6 +2,7 @@ export type FilterableLogEntry = { clientRequestId?: string; context?: string; filenames?: string[]; + message?: string; }; function uniqueStrings(values: string[]): string[] { diff --git a/src/lib/positive-integer-config.mjs b/src/lib/positive-integer-config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..3bb9d0a59688a93d84e36341b76d0f56f16e82c1 --- /dev/null +++ b/src/lib/positive-integer-config.mjs @@ -0,0 +1,33 @@ +const DIGITS_PATTERN = /^\d+$/; + +export const CHINESE_POSITIVE_INTEGER_MESSAGES = Object.freeze({ + digits: ({ name }) => `${name} 必须是正整数。`, + safeInteger: ({ name }) => `${name} 必须是正整数。`, + min: ({ name }) => `${name} 必须是正整数。` +}); + +export function parsePositiveIntegerConfig(value, name, fallback, options = {}) { + const rawValue = value === undefined || value === null ? '' : String(value).trim(); + if (!rawValue) return fallback; + + const minValue = options.minValue ?? 1; + const messages = options.messages || CHINESE_POSITIVE_INTEGER_MESSAGES; + const context = { name, minValue }; + + if (!DIGITS_PATTERN.test(rawValue)) { + throw new Error(messages.digits(context)); + } + + const parsed = Number(rawValue); + if (!Number.isSafeInteger(parsed)) { + throw new Error(messages.safeInteger(context)); + } + if (parsed < minValue) { + throw new Error(messages.min(context)); + } + return parsed; +} + +export function readPositiveIntegerFromEnv(env, name, fallback, options = {}) { + return parsePositiveIntegerConfig(env[name], name, fallback, options); +} diff --git a/src/lib/responses-image-backend.test.ts b/src/lib/responses-image-backend.test.ts index 7b8050405968837de70834b1b8402a29a07710d3..2b492dce26138b96c08c0e82e9a789e2bea6ed6f 100644 --- a/src/lib/responses-image-backend.test.ts +++ b/src/lib/responses-image-backend.test.ts @@ -1,26 +1,48 @@ -import { generateImageWithResponsesBackend } from './responses-image-backend'; +import { + createResponsesImageStream, + generateImageWithResponsesBackend, + type ResponsesImageGenerateInput +} from './responses-image-backend'; import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; +import type OpenAI from 'openai'; + +type ResponsesClient = ResponsesImageGenerateInput['responses']; +type ResponsesPayload = { + output?: unknown[]; + usage?: unknown; +}; + +function createResponsesClient( + handler: ( + params: OpenAI.Responses.ResponseCreateParamsNonStreaming | OpenAI.Responses.ResponseCreateParamsStreaming + ) => Promise> +): ResponsesClient { + const create = (async ( + params: OpenAI.Responses.ResponseCreateParamsNonStreaming | OpenAI.Responses.ResponseCreateParamsStreaming + ): Promise> => { + return handler(params); + }) as ResponsesClient['create']; + return { create }; +} describe('generateImageWithResponsesBackend', () => { it('calls the Responses API image_generation tool and reads image_generation_call.result', async () => { let capturedParams: unknown; const result = await generateImageWithResponsesBackend({ - responses: { - create: async (params: unknown) => { - capturedParams = params; - return { - output: [ - { - type: 'image_generation_call', - status: 'completed', - result: 'responses-final-base64' - } - ], - usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } - }; - } - }, + responses: createResponsesClient(async (params) => { + capturedParams = params; + return { + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: 'responses-final-base64' + } + ], + usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 } + }; + }), prompt: 'draw a test image', responsesModel: 'gpt-4.1', imageModel: 'gpt-image-2', @@ -57,17 +79,15 @@ describe('generateImageWithResponsesBackend', () => { await assert.rejects( () => generateImageWithResponsesBackend({ - responses: { - create: async () => ({ - output: [ - { - type: 'image_generation_call', - status: 'failed', - result: null - } - ] - }) - }, + responses: createResponsesClient(async () => ({ + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: null + } + ] + })), prompt: 'draw a test image', responsesModel: 'gpt-4.1', imageModel: 'gpt-image-2', @@ -80,4 +100,177 @@ describe('generateImageWithResponsesBackend', () => { /Responses API.*image_generation_call.result/ ); }); + + it('fails explicitly when the Responses API returns a failed image_generation_call', async () => { + await assert.rejects( + () => + generateImageWithResponsesBackend({ + responses: createResponsesClient(async () => ({ + output: [ + { + type: 'image_generation_call', + status: 'failed', + error: { + code: 'content_policy_violation', + message: 'blocked by upstream policy' + } + } + ] + })), + prompt: 'draw a test image', + responsesModel: 'gpt-4.1', + imageModel: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + outputFormat: 'png', + background: 'auto', + moderation: 'auto' + }), + /blocked by upstream policy/ + ); + }); + + it('fails explicitly when the Responses API returns only a remote image URL', async () => { + await assert.rejects( + () => + generateImageWithResponsesBackend({ + responses: createResponsesClient(async () => ({ + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: 'https://example.test/image.png' + } + ] + })), + prompt: 'draw a test image', + responsesModel: 'gpt-4.1', + imageModel: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + outputFormat: 'png', + background: 'auto', + moderation: 'auto' + }), + /远程图片 URL/ + ); + + await assert.rejects( + () => + generateImageWithResponsesBackend({ + responses: createResponsesClient(async () => ({ + output: [ + { + type: 'image_generation_call', + status: 'completed', + url: 'https://example.test/image.png' + } + ] + })), + prompt: 'draw a test image', + responsesModel: 'gpt-4.1', + imageModel: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + outputFormat: 'png', + background: 'auto', + moderation: 'auto' + }), + /远程图片 URL/ + ); + }); + + it('extracts base64 payloads from Responses API data URL results', async () => { + const result = await generateImageWithResponsesBackend({ + responses: createResponsesClient(async () => ({ + output: [ + { + type: 'image_generation_call', + status: 'completed', + result: 'data:image/png;base64,responses-final-base64' + } + ] + })), + prompt: 'draw a test image', + responsesModel: 'gpt-4.1', + imageModel: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + outputFormat: 'png', + background: 'auto', + moderation: 'auto' + }); + + assert.deepEqual(result.data, [{ b64_json: 'responses-final-base64' }]); + }); + + it('accepts completed Responses image results even when status is omitted', async () => { + const result = await generateImageWithResponsesBackend({ + responses: createResponsesClient(async () => ({ + output: [ + { + type: 'image_generation_call', + result: 'responses-final-without-status' + } + ] + })), + prompt: 'draw a test image', + responsesModel: 'gpt-4.1', + imageModel: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + outputFormat: 'png', + background: 'auto', + moderation: 'auto' + }); + + assert.deepEqual(result.data, [{ b64_json: 'responses-final-without-status' }]); + }); + + it('calls the Responses API image_generation tool in streaming mode with partial images enabled', async () => { + let capturedParams: unknown; + const streamEvents = async function* () { + yield { + type: 'response.output_item.done', + item: { type: 'image_generation_call', result: 'stream-final-base64' } + }; + }; + + const stream = await createResponsesImageStream({ + responses: createResponsesClient(async (params) => { + capturedParams = params; + return streamEvents(); + }), + prompt: 'draw a streaming test image', + responsesModel: 'gpt-4.1', + imageModel: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + outputFormat: 'png', + background: 'auto', + moderation: 'auto', + partialImagesCount: 2 + }); + + assert.equal(typeof stream[Symbol.asyncIterator], 'function'); + assert.deepEqual(capturedParams, { + model: 'gpt-4.1', + input: 'draw a streaming test image', + stream: true, + tool_choice: { type: 'image_generation' }, + tools: [ + { + type: 'image_generation', + action: 'generate', + model: 'gpt-image-2', + size: '1024x1024', + quality: 'high', + output_format: 'png', + background: 'auto', + moderation: 'auto', + partial_images: 2 + } + ] + }); + }); }); diff --git a/src/lib/responses-image-backend.ts b/src/lib/responses-image-backend.ts index e5951aed06e818271171bbbcb321f64493bcd376..dd539e02bf87f8566e8100c27b5468a3fd90d234 100644 --- a/src/lib/responses-image-backend.ts +++ b/src/lib/responses-image-backend.ts @@ -4,10 +4,17 @@ import type OpenAI from 'openai'; type ImageUsage = OpenAI.Images.ImagesResponse['usage']; type ResponsesCreateClient = { - create(params: OpenAI.Responses.ResponseCreateParamsNonStreaming): Promise<{ + create( + params: OpenAI.Responses.ResponseCreateParamsNonStreaming, + options?: OpenAI.RequestOptions + ): Promise<{ output?: unknown[]; usage?: unknown; }>; + create( + params: OpenAI.Responses.ResponseCreateParamsStreaming, + options?: OpenAI.RequestOptions + ): Promise>; }; export type ResponsesImageGenerateInput = { @@ -21,8 +28,59 @@ export type ResponsesImageGenerateInput = { background: 'transparent' | 'opaque' | 'auto'; moderation: 'auto' | 'low'; outputCompression?: number; + abortSignal?: AbortSignal; }; +export type ResponsesImageStreamInput = ResponsesImageGenerateInput & { + partialImagesCount: 1 | 2 | 3; +}; + +class ResponsesImageGenerationError extends Error { + readonly status = 502; + + constructor(message: string) { + super(message); + this.name = 'ResponsesImageGenerationError'; + } +} + +function extractBase64FromDataUrl(value: string): string | undefined { + if (!value.startsWith('data:')) return undefined; + const separator = value.indexOf(','); + if (separator < 0) return undefined; + const payload = value.slice(separator + 1).trim(); + return payload || undefined; +} + +function isRemoteHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === 'http:' || url.protocol === 'https:'; + } catch { + return false; + } +} + +function readImageGenerationResultBase64(value: string): string | undefined { + const normalized = value.trim(); + if (!normalized) return undefined; + const dataUrlPayload = extractBase64FromDataUrl(normalized); + if (dataUrlPayload) return dataUrlPayload; + if (isRemoteHttpUrl(normalized)) return undefined; + return normalized; +} + +function readCompletedImageRecordBase64(record: Record): string | undefined { + if (typeof record.result === 'string') { + const resultBase64 = readImageGenerationResultBase64(record.result); + if (resultBase64) return resultBase64; + } + if (typeof record.url === 'string') { + return extractBase64FromDataUrl(record.url); + } + return undefined; +} + function extractCompletedImageResults(output: unknown[] | undefined): string[] { if (!Array.isArray(output)) { return []; @@ -33,17 +91,55 @@ function extractCompletedImageResults(output: unknown[] | undefined): string[] { if (!record || record.type !== 'image_generation_call') { return []; } - if (record.status !== 'completed' || typeof record.result !== 'string' || !record.result.trim()) { + if (record.status === 'failed') { return []; } - return [record.result.trim()]; + const b64Json = readCompletedImageRecordBase64(record); + return b64Json ? [b64Json] : []; }); } -export async function generateImageWithResponsesBackend( - input: ResponsesImageGenerateInput -): Promise { - const imageTool: OpenAI.Responses.Tool = { +function readResponseImageError(record: Record): string | undefined { + const error = asRecord(record.error); + if (error) { + if (typeof error.message === 'string' && error.message.trim()) return error.message.trim(); + if (typeof error.code === 'string' && error.code.trim()) return error.code.trim(); + } + if (typeof record.error === 'string' && record.error.trim()) return record.error.trim(); + if (typeof record.message === 'string' && record.message.trim()) return record.message.trim(); + return undefined; +} + +function readFailedImageGenerationCallMessage(output: unknown[] | undefined): string | undefined { + if (!Array.isArray(output)) return undefined; + for (const item of output) { + const record = asRecord(item); + if (!record || record.type !== 'image_generation_call' || record.status !== 'failed') continue; + return readResponseImageError(record) || '上游未提供错误信息。'; + } + return undefined; +} + +function hasRemoteOnlyCompletedImageResult(output: unknown[] | undefined): boolean { + if (!Array.isArray(output)) return false; + return output.some((item) => { + const record = asRecord(item); + if (!record) return false; + return ( + record.type === 'image_generation_call' && + record.status !== 'failed' && + ((typeof record.result === 'string' && isRemoteHttpUrl(record.result.trim())) || + (typeof record.url === 'string' && isRemoteHttpUrl(record.url.trim()))) && + !readCompletedImageRecordBase64(record) + ); + }); +} + +function buildResponsesImageTool( + input: ResponsesImageGenerateInput, + partialImagesCount?: 1 | 2 | 3 +): OpenAI.Responses.Tool { + return { type: 'image_generation', action: 'generate', model: input.imageModel, @@ -52,19 +148,37 @@ export async function generateImageWithResponsesBackend( output_format: input.outputFormat, background: input.background, moderation: input.moderation, - ...(input.outputCompression !== undefined ? { output_compression: input.outputCompression } : {}) + ...(input.outputCompression !== undefined ? { output_compression: input.outputCompression } : {}), + ...(partialImagesCount !== undefined ? { partial_images: partialImagesCount } : {}) }; - const response = await input.responses.create({ - model: input.responsesModel, - input: input.prompt, - stream: false, - tool_choice: { type: 'image_generation' }, - tools: [imageTool] - }); +} + +export async function generateImageWithResponsesBackend( + input: ResponsesImageGenerateInput +): Promise { + const response = await input.responses.create( + { + model: input.responsesModel, + input: input.prompt, + stream: false, + tool_choice: { type: 'image_generation' }, + tools: [buildResponsesImageTool(input)] + }, + input.abortSignal ? { signal: input.abortSignal } : undefined + ); const imageResults = extractCompletedImageResults(response.output); if (imageResults.length === 0) { - throw new Error('Responses API 未返回已完成的 image_generation_call.result。'); + const failedMessage = readFailedImageGenerationCallMessage(response.output); + if (failedMessage) { + throw new ResponsesImageGenerationError(`Responses API image_generation_call 失败:${failedMessage}`); + } + if (hasRemoteOnlyCompletedImageResult(response.output)) { + throw new ResponsesImageGenerationError( + 'Responses API 只返回远程图片 URL,缺少可保存的 image_generation_call.result base64 数据。' + ); + } + throw new ResponsesImageGenerationError('Responses API 未返回已完成的 image_generation_call.result。'); } return { @@ -73,3 +187,16 @@ export async function generateImageWithResponsesBackend( ...(response.usage ? { usage: response.usage as ImageUsage } : {}) }; } + +export async function createResponsesImageStream(input: ResponsesImageStreamInput): Promise> { + return input.responses.create( + { + model: input.responsesModel, + input: input.prompt, + stream: true, + tool_choice: { type: 'image_generation' }, + tools: [buildResponsesImageTool(input, input.partialImagesCount)] + }, + input.abortSignal ? { signal: input.abortSignal } : undefined + ); +} diff --git a/src/lib/server-runtime.ts b/src/lib/server-runtime.ts index 96899982dcdebacdb8a895fb67d01072817e9177..081a5c4a0fc6c26aac619219fb2192afabc5e234 100644 --- a/src/lib/server-runtime.ts +++ b/src/lib/server-runtime.ts @@ -1,5 +1,9 @@ import crypto from 'crypto'; import path from 'path'; +import { + CHINESE_POSITIVE_INTEGER_MESSAGES, + readPositiveIntegerFromEnv +} from './positive-integer-config.mjs'; import type { ValidOutputFormat } from './image-request-utils'; export type FilenameClock = () => number; @@ -149,14 +153,7 @@ export function readPositiveIntegerEnv( fieldName: string, fallback: number ): number { - const value = env[fieldName]?.trim(); - if (!value) return fallback; - if (!/^\d+$/.test(value)) { - throw new Error(`${fieldName} 必须是正整数。`); - } - const parsed = Number(value); - if (!Number.isSafeInteger(parsed) || parsed < 1) { - throw new Error(`${fieldName} 必须是正整数。`); - } - return parsed; + return readPositiveIntegerFromEnv(env, fieldName, fallback, { + messages: CHINESE_POSITIVE_INTEGER_MESSAGES + }); } diff --git a/src/lib/streaming-batch.test.ts b/src/lib/streaming-batch.test.ts index f911f3624b785efe3a331b5bd0c3307261fe02ec..3593dcf88445c2a5f94d5e065c43f56886cb7aac 100644 --- a/src/lib/streaming-batch.test.ts +++ b/src/lib/streaming-batch.test.ts @@ -58,7 +58,6 @@ describe('computeStreamingBatchRecommendation', () => { assert.equal( computeStreamingBatchRecommendation({ credentialCount: 5, - channelCount: 3, maxStreamsPerCredential: 2, strategy: 'sticky' }),