Spaces:
Running
Running
Deploy 574ece8 to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@574ece844389b5e91bb1828dc3af015a64845e4a
This view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +1 -1
- .gitignore +3 -0
- AGENTS.md +2 -0
- CHANGELOG.md +4 -4
- README.md +57 -6
- docs/deployment/huggingface-space-free.md +134 -12
- docs/superpowers/plans/2026-05-15-image-download-share.md +20 -20
- package.json +5 -1
- scripts/agent-skill-scripts.test.mjs +185 -0
- scripts/check-node-syntax.mjs +39 -0
- scripts/doctor-hf-space.mjs +299 -0
- scripts/hf-space-doctor-utils.mjs +166 -0
- scripts/hf-space-doctor-utils.test.mjs +101 -0
- scripts/init-hf-space-access.mjs +114 -0
- scripts/init-hf-space-access.test.mjs +108 -0
- scripts/keepalive-hf-space.mjs +10 -1
- scripts/keepalive-hf-space.test.mjs +37 -0
- scripts/smoke-hf-space-memory.mjs +9 -0
- scripts/sync-hf-space-secret.mjs +119 -39
- scripts/sync-hf-space-secret.test.mjs +151 -0
- skills/gpt-image-playground-agent/SKILL.md +68 -11
- skills/gpt-image-playground-agent/references/api.md +131 -3
- skills/gpt-image-playground-agent/scripts/edit-image.mjs +131 -31
- skills/gpt-image-playground-agent/scripts/generate-image.mjs +373 -76
- skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs +71 -0
- skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs +253 -0
- src/app/api/agent/agent-routes.test.ts +466 -2
- src/app/api/agent/images/edit/route.ts +5 -5
- src/app/api/agent/images/generate/route.ts +4 -2
- src/app/api/agent/jobs/[id]/result/route.ts +51 -0
- src/app/api/agent/jobs/[id]/route.ts +32 -0
- src/app/api/agent/jobs/images/generate/route.ts +78 -0
- src/app/api/agent/openapi.json/route.ts +1 -1
- src/app/api/auth-verify/route.test.ts +2 -2
- src/app/api/auth-verify/route.ts +1 -1
- src/app/api/image-delete/route.ts +4 -4
- src/app/api/image-route.test.ts +4 -4
- src/app/api/images/route.ts +4 -4
- src/app/api/logs/route.test.ts +3 -3
- src/app/api/logs/route.ts +1 -1
- src/app/api/shares/[token]/content/route.ts +4 -1
- src/app/api/shares/route.test.ts +68 -3
- src/app/api/shares/route.ts +15 -1
- src/app/page.tsx +2 -2
- src/components/password-dialog.tsx +1 -1
- src/lib/agent-api-contracts.test.ts +117 -10
- src/lib/agent-api-contracts.ts +175 -331
- src/lib/agent-auth.test.ts +4 -4
- src/lib/agent-auth.ts +1 -1
- src/lib/agent-image-service.test.ts +6 -0
.env.example
CHANGED
|
@@ -49,7 +49,7 @@ OPENAI_API_BASE_URL=
|
|
| 49 |
# ENABLE_RESPONSES_IMAGE_BACKEND=true
|
| 50 |
# OPENAI_RESPONSES_API_MODEL=
|
| 51 |
|
| 52 |
-
# 可选:给网页加一个访问
|
| 53 |
APP_PASSWORD=
|
| 54 |
|
| 55 |
# 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
|
|
|
|
| 49 |
# ENABLE_RESPONSES_IMAGE_BACKEND=true
|
| 50 |
# OPENAI_RESPONSES_API_MODEL=
|
| 51 |
|
| 52 |
+
# 可选:给网页加一个访问码。公网部署时建议一定要设置。
|
| 53 |
APP_PASSWORD=
|
| 54 |
|
| 55 |
# 可选:给 /api/agent/* 使用的 Bearer token。公网或内网共享部署时建议设置。
|
.gitignore
CHANGED
|
@@ -49,3 +49,6 @@ generated-images/
|
|
| 49 |
|
| 50 |
# generated verification artifacts
|
| 51 |
/artifacts/
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
|
| 50 |
# generated verification artifacts
|
| 51 |
/artifacts/
|
| 52 |
+
|
| 53 |
+
# local agent/tool configuration
|
| 54 |
+
.antigravitycli/
|
AGENTS.md
CHANGED
|
@@ -86,6 +86,7 @@
|
|
| 86 |
```bash
|
| 87 |
npm test
|
| 88 |
npm run lint
|
|
|
|
| 89 |
npm run build
|
| 90 |
git diff --check
|
| 91 |
```
|
|
@@ -125,6 +126,7 @@ docker compose up -d --build
|
|
| 125 |
```bash
|
| 126 |
npm test
|
| 127 |
npm run lint
|
|
|
|
| 128 |
npm run build
|
| 129 |
git diff --check
|
| 130 |
```
|
|
|
|
| 86 |
```bash
|
| 87 |
npm test
|
| 88 |
npm run lint
|
| 89 |
+
npm run lint:scripts
|
| 90 |
npm run build
|
| 91 |
git diff --check
|
| 92 |
```
|
|
|
|
| 126 |
```bash
|
| 127 |
npm test
|
| 128 |
npm run lint
|
| 129 |
+
npm run lint:scripts
|
| 130 |
npm run build
|
| 131 |
git diff --check
|
| 132 |
```
|
CHANGELOG.md
CHANGED
|
@@ -38,7 +38,7 @@
|
|
| 38 |
### 变更
|
| 39 |
|
| 40 |
- 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。
|
| 41 |
-
- 图片请求构造、流式响应处理和
|
| 42 |
- README 与 `.env.example` 补充流式批处理、单 credential 并发上限和渠道失败冷却配置说明。
|
| 43 |
- ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。
|
| 44 |
|
|
@@ -48,7 +48,7 @@
|
|
| 48 |
- 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。
|
| 49 |
- 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。
|
| 50 |
- 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。
|
| 51 |
-
- 修正
|
| 52 |
|
| 53 |
## [1.2.0] - 2026-05-11
|
| 54 |
|
|
@@ -58,12 +58,12 @@
|
|
| 58 |
- 支持 `sticky`、`round_robin`、`random` 三种服务端凭证路由策略。
|
| 59 |
- 增加渠道解析、路由选择、有效凭证解析的单元测试。
|
| 60 |
- 增加仓库执行约束文档 `AGENTS.md`。
|
| 61 |
-
- 增加服务端运行时工具测试,覆盖
|
| 62 |
- 增加应用日志工具测试,覆盖日志等级规范化、默认等级、无效配置回退和上下文透传。
|
| 63 |
### 变更
|
| 64 |
|
| 65 |
- 统一本地服务默认使用 `4783` 端口启动。
|
| 66 |
-
- 抽取服务端运行时工具,复用
|
| 67 |
- 将服务端请求路径日志收敛为可配置日志等级,生产环境默认只输出警告和错误。
|
| 68 |
- 缓存日志等级解析结果,减少热路径重复计算。
|
| 69 |
|
|
|
|
| 38 |
### 变更
|
| 39 |
|
| 40 |
- 前端提交图片请求前会刷新运行时能力,并按用户自填 API Key 或服务端渠道池选择不同并发窗口。
|
| 41 |
+
- 图片请求构造、流式响应处理和访问码重试参数改为可复用流程,批处理和单请求共用同一套错误处理。
|
| 42 |
- README 与 `.env.example` 补充流式批处理、单 credential 并发上限和渠道失败冷却配置说明。
|
| 43 |
- ESLint 配置显式绑定 Next.js 根目录,TypeScript 配置排除 `dist` 构建产物。
|
| 44 |
|
|
|
|
| 48 |
- 修正服务端渠道池全部冷却时前端仍可能按旧推荐并发继续批处理的问题。
|
| 49 |
- 修正 OpenAI SDK 将连接错误放在嵌套 `cause` 中时未触发 channel 冷却的问题。
|
| 50 |
- 兼容上游错误中的 `requestID` 和 `requestId` 字段,并确保公开能力接口不返回上游错误消息。
|
| 51 |
+
- 修正访问码弹窗重试只保存表单数据、未保存请求模式和流式参数的问题。
|
| 52 |
|
| 53 |
## [1.2.0] - 2026-05-11
|
| 54 |
|
|
|
|
| 58 |
- 支持 `sticky`、`round_robin`、`random` 三种服务端凭证路由策略。
|
| 59 |
- 增加渠道解析、路由选择、有效凭证解析的单元测试。
|
| 60 |
- 增加仓库执行约束文档 `AGENTS.md`。
|
| 61 |
+
- 增加服务端运行时工具测试,覆盖访问码哈希校验、请求来源选择、批次 ID 和图片文件名生成。
|
| 62 |
- 增加应用日志工具测试,覆盖日志等级规范化、默认等级、无效配置回退和上下文透传。
|
| 63 |
### 变更
|
| 64 |
|
| 65 |
- 统一本地服务默认使用 `4783` 端口启动。
|
| 66 |
+
- 抽取服务端运行时工具,复用访问码校验、输出目录和文件名生成逻辑。
|
| 67 |
- 将服务端请求路径日志收敛为可配置日志等级,生产环境默认只输出警告和错误。
|
| 68 |
- 缓存日志等级解析结果,减少热路径重复计算。
|
| 69 |
|
README.md
CHANGED
|
@@ -131,7 +131,7 @@ http://localhost:4783
|
|
| 131 |
- 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
|
| 132 |
- 发送到编辑:从生成结果或历史记录直接进入编辑模式。
|
| 133 |
- 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
|
| 134 |
-
- 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加
|
| 135 |
- Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。
|
| 136 |
- 双语和主题:支持中文、英文、亮色、暗色。
|
| 137 |
- 两种图片存储模式:服务端文件系统或浏览器 IndexedDB。
|
|
@@ -199,6 +199,9 @@ Agent API 面向自动化调用,不要求 Agent 模拟网页表单。接口统
|
|
| 199 |
| `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 |
|
| 200 |
| `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 |
|
| 201 |
| `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 |
|
|
|
|
|
|
|
|
|
|
| 202 |
| `GET /api/agent/artifacts/{id}` | 查询产物元数据。 |
|
| 203 |
| `GET /api/agent/artifacts/{id}/content` | 下载产物图片内容。 |
|
| 204 |
| `DELETE /api/agent/artifacts/{id}` | 删除产物和元数据。 |
|
|
@@ -209,6 +212,13 @@ Agent 请求必须带 `Idempotency-Key`,避免超时重试造成重复出图
|
|
| 209 |
Authorization: Bearer your-agent-token
|
| 210 |
```
|
| 211 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
生成示例:
|
| 213 |
|
| 214 |
```bash
|
|
@@ -294,7 +304,7 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case
|
|
| 294 |
| `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 |
|
| 295 |
| `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 |
|
| 296 |
| `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 |
|
| 297 |
-
| `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问
|
| 298 |
| `AGENT_API_TOKEN` | 否 | 无 | 设置后,`/api/agent/*` 需要 Bearer token。 |
|
| 299 |
| `AGENT_STATE_BACKEND` | 否 | `sqlite` | Agent 状态后端,可选 `memory`、`sqlite` 或 `postgres`。 |
|
| 300 |
| `AGENT_SQLITE_PATH` | 否 | `generated-images/.agent-state/agent.sqlite` | SQLite 状态库路径。 |
|
|
@@ -305,7 +315,7 @@ Web 流式 `/api/images` 事件会同时提供 camelCase 字段和旧 snake_case
|
|
| 305 |
| `AGENT_REQUEST_LEASE_MS` | 否 | `600000` | Agent 请求运行锁租约时间。 |
|
| 306 |
| `AGENT_REQUEST_TTL_SECONDS` | 否 | `86400` | 幂等请求记录保留秒数。 |
|
| 307 |
| `AGENT_RECOVERY_INTERVAL_MS` | 否 | `30000` | Agent 请求触发轻量 recovery 的最小间隔。 |
|
| 308 |
-
| `AGENT_PUBLIC_BASE_URL` | 否 | `/` | OpenAPI `servers[0].url`,供外部 Agent 生成客户端时使用。 |
|
| 309 |
| `APP_LOG_LEVEL` | 否 | 生产环境 `warn`,其他环境 `info` | 服务端日志等级,可选 `debug`、`info`、`warn`、`error`。 |
|
| 310 |
| `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 否 | `fs` | 可选 `fs` 或 `indexeddb`。 |
|
| 311 |
|
|
@@ -441,9 +451,47 @@ NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
|
|
| 441 |
|
| 442 |
本仓库也提供 `docker-compose.memory.yml` 作为本地模拟模板;Hugging Face Docker Space 通常直接通过 Space Variables 和 Secrets 设置环境变量,不需要提交 `.env.local`。完整部署步骤见 [Hugging Face Space 免费层部署](./docs/deployment/huggingface-space-free.md)。
|
| 443 |
|
| 444 |
-
免费 CPU Basic 会在长时间无访问后休眠。本仓库提供 `.github/workflows/hf-space-keepalive.yml`,默认每 6 小时访问一次 `/api/auth-status`,只做只读 keepalive,不携带
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
|
| 446 |
-
如果修改了本机访问记录文件里的 `APP_PASSWORD`,可执行 `npm run sync-secret:hf-space` 同步到 Hugging Face Space Secret、重启并验证新
|
| 447 |
|
| 448 |
`memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。
|
| 449 |
|
|
@@ -494,10 +542,13 @@ docker logs -f gpt-image-playground-customer
|
|
| 494 |
| `npm run dev` | 启动本地开发服务。 |
|
| 495 |
| `npm run build` | 执行生产构建。 |
|
| 496 |
| `npm run start` | 启动生产模式服务。 |
|
|
|
|
|
|
|
| 497 |
| `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
|
| 498 |
-
| `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面
|
| 499 |
| `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约。 |
|
| 500 |
| `npm run lint` | 检查 `src/` 代码。 |
|
|
|
|
| 501 |
| `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 |
|
| 502 |
|
| 503 |
## 常见问题
|
|
|
|
| 131 |
- 历史记录:保留提示词、参数、图片、耗时、token 使用量和估算费用。
|
| 132 |
- 发送到编辑:从生成结果或历史记录直接进入编辑模式。
|
| 133 |
- 下载与分享:单图结果可直接下载,分享链接支持访问码和有效期。
|
| 134 |
+
- 页面访问保护:可通过 `APP_PASSWORD` 给网页和受保护图片访问加访问码。
|
| 135 |
- Agent 状态后端:支持 `memory`、`sqlite`、`postgres`,覆盖临时演示、单实例和集中状态库场景。
|
| 136 |
- 双语和主题:支持中文、英文、亮色、暗色。
|
| 137 |
- 两种图片存储模式:服务端文件系统或浏览器 IndexedDB。
|
|
|
|
| 199 |
| `GET /api/agent/openapi.json` | 获取机器可读 OpenAPI 描述。 |
|
| 200 |
| `POST /api/agent/images/generate` | JSON 文生图,默认只返回文件路径和元数据。 |
|
| 201 |
| `POST /api/agent/images/edit` | multipart 图片编辑,支持源图和 PNG mask。 |
|
| 202 |
+
| `POST /api/agent/jobs/images/generate` | 创建文生图 job,适合 4K/high 或长耗时请求。 |
|
| 203 |
+
| `GET /api/agent/jobs/{id}` | 轮询 job 状态。 |
|
| 204 |
+
| `GET /api/agent/jobs/{id}/result` | 读取完成后的标准图片响应,运行中返回可重试错误。 |
|
| 205 |
| `GET /api/agent/artifacts/{id}` | 查询产物元数据。 |
|
| 206 |
| `GET /api/agent/artifacts/{id}/content` | 下载产物图片内容。 |
|
| 207 |
| `DELETE /api/agent/artifacts/{id}` | 删除产物和元数据。 |
|
|
|
|
| 212 |
Authorization: Bearer your-agent-token
|
| 213 |
```
|
| 214 |
|
| 215 |
+
`AGENT_API_TOKEN` 存在时 Agent API 只接受 Bearer token,不会回退到页面访问码哈希。只有未设置 `AGENT_API_TOKEN` 且设置了 `APP_PASSWORD` 时,Agent API 才接受 `X-App-Password-Hash`;实际可用方案以 `/api/agent/capabilities` 的 `auth.schemes` 为准。
|
| 216 |
+
|
| 217 |
+
同一个 `Idempotency-Key` 如果已进入终态 `failed`,再次请求只会回放该失败,不会重新执行。终态失败回放会返回 `retryable=false`,并保留错误码、上游状态和脱敏诊断字段;需要重新尝试时,应创建新的业务操作和新的 `Idempotency-Key`。
|
| 218 |
+
|
| 219 |
+
Job polling 当前是同一 Next.js 服务实例内的后台任务,结果和错误会写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应继续按状态端点和结构化错误处理,必要时用相同 `Idempotency-Key` 重建同一业务操作。
|
| 220 |
+
运行中的 job 会定时刷新请求 lease,避免高质量长耗时上游调用仍在执行时被 recovery 误判为孤儿请求。
|
| 221 |
+
|
| 222 |
生成示例:
|
| 223 |
|
| 224 |
```bash
|
|
|
|
| 304 |
| `OPENAI_RESPONSES_API_MODEL` | 否 | 无 | Responses API 实验后端的 `/responses` 顶层模型。启用 `imageBackend=responses` 时必须设置,或在请求中传 `responsesModel`。 |
|
| 305 |
| `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 否 | `1` | 每个服务端 credential 允许同时执行的流式任务数。 |
|
| 306 |
| `OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 否 | `60000` | 服务端 credential 或 channel 失败后的默认冷却时间。 |
|
| 307 |
+
| `APP_PASSWORD` | 否 | 无 | 设置后,页面会要求输入访问码。 |
|
| 308 |
| `AGENT_API_TOKEN` | 否 | 无 | 设置后,`/api/agent/*` 需要 Bearer token。 |
|
| 309 |
| `AGENT_STATE_BACKEND` | 否 | `sqlite` | Agent 状态后端,可选 `memory`、`sqlite` 或 `postgres`。 |
|
| 310 |
| `AGENT_SQLITE_PATH` | 否 | `generated-images/.agent-state/agent.sqlite` | SQLite 状态库路径。 |
|
|
|
|
| 315 |
| `AGENT_REQUEST_LEASE_MS` | 否 | `600000` | Agent 请求运行锁租约时间。 |
|
| 316 |
| `AGENT_REQUEST_TTL_SECONDS` | 否 | `86400` | 幂等请求记录保留秒数。 |
|
| 317 |
| `AGENT_RECOVERY_INTERVAL_MS` | 否 | `30000` | Agent 请求触发轻量 recovery 的最小间隔。 |
|
| 318 |
+
| `AGENT_PUBLIC_BASE_URL` | 否 | `/` | OpenAPI `servers[0].url`,供外部 Agent 生成客户端时使用;配置时必须是绝对 `http`/`https` URL,不能包含凭据、查询参数或片段。 |
|
| 319 |
| `APP_LOG_LEVEL` | 否 | 生产环境 `warn`,其他环境 `info` | 服务端日志等级,可选 `debug`、`info`、`warn`、`error`。 |
|
| 320 |
| `NEXT_PUBLIC_IMAGE_STORAGE_MODE` | 否 | `fs` | 可选 `fs` 或 `indexeddb`。 |
|
| 321 |
|
|
|
|
| 451 |
|
| 452 |
本仓库也提供 `docker-compose.memory.yml` 作为本地模拟模板;Hugging Face Docker Space 通常直接通过 Space Variables 和 Secrets 设置环境变量,不需要提交 `.env.local`。完整部署步骤见 [Hugging Face Space 免费层部署](./docs/deployment/huggingface-space-free.md)。
|
| 453 |
|
| 454 |
+
免费 CPU Basic 会在长时间无访问后休眠。本仓库提供 `.github/workflows/hf-space-keepalive.yml`,默认每 6 小时访问一次 `/api/auth-status`,只做只读 keepalive,不携带访问码或 token,不触发生图。若 Space 地址变化,在 GitHub 仓库 Variables 中设置 `HF_SPACE_KEEPALIVE_URL`。
|
| 455 |
+
|
| 456 |
+
全新电脑从 0 开始时,先完成系统级前置条件:
|
| 457 |
+
|
| 458 |
+
```bash
|
| 459 |
+
node --version
|
| 460 |
+
npm --version
|
| 461 |
+
hf --help
|
| 462 |
+
hf auth login
|
| 463 |
+
npm install
|
| 464 |
+
```
|
| 465 |
+
|
| 466 |
+
要求 Node.js 20 或更高版本。Hugging Face CLI 安装方式以官方文档为准;当前官方入口是 `hf` 命令,登录使用 Hugging Face Access Token。
|
| 467 |
+
|
| 468 |
+
不同用户首次接手自己的 Space 时,先生成本机访问记录文件:
|
| 469 |
+
|
| 470 |
+
```bash
|
| 471 |
+
npm run init-access:hf-space -- \
|
| 472 |
+
--space-id <namespace>/<space-name> \
|
| 473 |
+
--space-url https://<user>-<space>.hf.space
|
| 474 |
+
```
|
| 475 |
+
|
| 476 |
+
脚本会写入 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,生成访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`,并记录 `HF_SPACE_ID`、`HF_SPACE_URL` 和默认同步 key。脚本不会打印 Secret 值,且默认不覆盖已有文件。
|
| 477 |
+
`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。
|
| 478 |
+
|
| 479 |
+
创建本机访问记录文件不需要 Hugging Face 账号密码。同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token:
|
| 480 |
+
|
| 481 |
+
```bash
|
| 482 |
+
hf auth whoami
|
| 483 |
+
hf auth login
|
| 484 |
+
```
|
| 485 |
+
|
| 486 |
+
如果不确定当前机器缺什么,先运行只读诊断:
|
| 487 |
+
|
| 488 |
+
```bash
|
| 489 |
+
npm run doctor:hf-space
|
| 490 |
+
```
|
| 491 |
+
|
| 492 |
+
该命令会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置;不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
|
| 493 |
|
| 494 |
+
如果修改了本机访问记录文件里的 `APP_PASSWORD`,可执行 `npm run sync-secret:hf-space` 同步到 Hugging Face Space Secret、重启并验证新访问码。脚本默认读取 `~/.cache/gpt-image-playground-customer/hf-space-access.txt`,并要求目标 Space 写在 access 文件或环境变量里,避免不同用户误写到示例 Space;输出不会回显 Secret 值。
|
| 495 |
|
| 496 |
`memory` 模式不创建 SQLite 文件,也不连接 PostgreSQL。它只适合无持久化演示、短会话调试或可接受重启丢失 Agent 幂等状态的环境;容器重启后请求记录、artifact 元数据和 replay 状态都会清空。Web 图片二进制按 `NEXT_PUBLIC_IMAGE_STORAGE_MODE` 保存;HF 免费层推荐 `indexeddb`,让网页结果保存在浏览器侧。Agent API 产物仍写入容器临时文件系统,以便提供 `content_url` 下载。
|
| 497 |
|
|
|
|
| 542 |
| `npm run dev` | 启动本地开发服务。 |
|
| 543 |
| `npm run build` | 执行生产构建。 |
|
| 544 |
| `npm run start` | 启动生产模式服务。 |
|
| 545 |
+
| `npm run doctor:hf-space` | 只读诊断 HF Space 部署前置条件、本机 access 文件和远端配置。 |
|
| 546 |
+
| `npm run init-access:hf-space` | 为当前用户生成本机 HF Space 访问记录、随机访问码和 Agent token。 |
|
| 547 |
| `npm run keepalive:hf-space` | 访问 HF Space 只读状态端点,用于 keepalive 验证。 |
|
| 548 |
+
| `npm run sync-secret:hf-space` | 从本机访问记录文件同步 HF Space Secret,并验证页面访问码。 |
|
| 549 |
| `npm run smoke:hf-space` | 构建并启动 HF 免费层近似容器,验证 memory 状态后端和 Agent API 契约。 |
|
| 550 |
| `npm run lint` | 检查 `src/` 代码。 |
|
| 551 |
+
| `npm run lint:scripts` | 跨平台检查仓库脚本和 skill 脚本语法。 |
|
| 552 |
| `npm run format` | 格式化 `src/` 下的 TypeScript 和 React 文件。 |
|
| 553 |
|
| 554 |
## 常见问题
|
docs/deployment/huggingface-space-free.md
CHANGED
|
@@ -24,6 +24,44 @@ app_port: 4783
|
|
| 24 |
|
| 25 |
- Docker Space 配置、Variables/Secrets 和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker
|
| 26 |
- 免费 CPU Basic 规格说明:https://huggingface.co/docs/hub/main/spaces-gpus
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
## Space Variables
|
| 29 |
|
|
@@ -31,23 +69,30 @@ app_port: 4783
|
|
| 31 |
|
| 32 |
```dotenv
|
| 33 |
AGENT_STATE_BACKEND=memory
|
| 34 |
-
AGENT_SQLITE_PATH=
|
| 35 |
-
AGENT_DATABASE_URL=
|
| 36 |
-
AGENT_DB_PASSWORD=
|
| 37 |
-
AGENT_DB_PASSWORD_FILE=
|
| 38 |
NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
|
| 39 |
APP_LOG_LEVEL=warn
|
| 40 |
```
|
| 41 |
|
| 42 |
`NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明 build arg,Hugging Face Docker Space 会把同名 Variable 作为 build arg 传入构建,并在运行期注入环境变量。
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
可选:
|
| 45 |
|
| 46 |
```dotenv
|
| 47 |
AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
|
| 48 |
```
|
| 49 |
|
| 50 |
-
`AGENT_PUBLIC_BASE_URL` 只影响 OpenAPI `servers[0].url`,Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。
|
| 51 |
|
| 52 |
## Space Secrets
|
| 53 |
|
|
@@ -56,11 +101,11 @@ AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
|
|
| 56 |
```dotenv
|
| 57 |
OPENAI_API_KEY=<your-api-key>
|
| 58 |
OPENAI_API_BASE_URL=https://api.openai.com/v1
|
| 59 |
-
APP_PASSWORD=<page-
|
| 60 |
AGENT_API_TOKEN=<long-random-agent-token>
|
| 61 |
```
|
| 62 |
|
| 63 |
-
公网部署建议至少设置 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
|
| 64 |
|
| 65 |
如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets:
|
| 66 |
|
|
@@ -74,7 +119,7 @@ OPENAI_CHANNEL_1_API_KEYS=<key-a>,<key-b>
|
|
| 74 |
## 手机网页使用
|
| 75 |
|
| 76 |
1. 打开 Space 地址,例如 `https://<user>-<space>.hf.space`。
|
| 77 |
-
2. 如果配置了 `APP_PASSWORD`,输入页面访问
|
| 78 |
3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。
|
| 79 |
4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。
|
| 80 |
|
|
@@ -95,6 +140,7 @@ node skills/gpt-image-playground-agent/scripts/generate-image.mjs
|
|
| 95 |
GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
|
| 96 |
GPT_IMAGE_AGENT_TOKEN=<agent-token> \
|
| 97 |
node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
|
|
|
| 98 |
"a product photo of a ceramic mug on a wooden table"
|
| 99 |
```
|
| 100 |
|
|
@@ -149,9 +195,77 @@ npm run keepalive:hf-space
|
|
| 149 |
|
| 150 |
注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。
|
| 151 |
|
| 152 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
```bash
|
| 157 |
npm run sync-secret:hf-space
|
|
@@ -163,7 +277,7 @@ npm run sync-secret:hf-space
|
|
| 163 |
~/.cache/gpt-image-playground-customer/hf-space-access.txt
|
| 164 |
```
|
| 165 |
|
| 166 |
-
默认同步 `APP_PASSWORD`,不会在输出中回显
|
| 167 |
|
| 168 |
```bash
|
| 169 |
HF_SPACE_ID=misonL/gpt-image-playground-customer \
|
|
@@ -173,10 +287,17 @@ HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN \
|
|
| 173 |
npm run sync-secret:hf-space
|
| 174 |
```
|
| 175 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
可选参数:
|
| 177 |
|
| 178 |
- `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。
|
| 179 |
-
- `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify`
|
|
|
|
| 180 |
|
| 181 |
## 验证门禁
|
| 182 |
|
|
@@ -185,6 +306,7 @@ npm run sync-secret:hf-space
|
|
| 185 |
```bash
|
| 186 |
npm test
|
| 187 |
npm run lint
|
|
|
|
| 188 |
npm run build
|
| 189 |
npm run keepalive:hf-space
|
| 190 |
npm run smoke:hf-space
|
|
|
|
| 24 |
|
| 25 |
- Docker Space 配置、Variables/Secrets 和权限说明:https://huggingface.co/docs/hub/main/spaces-sdks-docker
|
| 26 |
- 免费 CPU Basic 规格说明:https://huggingface.co/docs/hub/main/spaces-gpus
|
| 27 |
+
- Hugging Face CLI 安装和登录说明:https://huggingface.co/docs/huggingface_hub/en/guides/cli
|
| 28 |
+
|
| 29 |
+
## 全新电脑前置条件
|
| 30 |
+
|
| 31 |
+
全新用户、全新电脑需要先准备系统级工具。没有 Node.js 和 npm 时,仓库内 npm 脚本无法运行;没有 HF CLI 登录时,脚本无法把 Secret 写到远端 Space。
|
| 32 |
+
|
| 33 |
+
先检查:
|
| 34 |
+
|
| 35 |
+
```bash
|
| 36 |
+
node --version
|
| 37 |
+
npm --version
|
| 38 |
+
hf --help
|
| 39 |
+
hf auth whoami
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
要求:
|
| 43 |
+
|
| 44 |
+
- Node.js 20 或更高版本。
|
| 45 |
+
- npm 随 Node.js 一起可用。
|
| 46 |
+
- Hugging Face CLI 使用当前官方 `hf` 命令。
|
| 47 |
+
- `hf auth login` 使用 Hugging Face Access Token,不是账号密码。
|
| 48 |
+
- Docker 只对 `npm run smoke:hf-space` 和本地容器验证必需;只创建 txt 文件和同步 Secret 不需要 Docker。
|
| 49 |
+
|
| 50 |
+
安装 Hugging Face CLI 时,以官方文档为准。不要把远程安装脚本直接管道到 shell;如需使用官方脚本,先下载、核对来源和内容后再执行。
|
| 51 |
+
|
| 52 |
+
第一次拉取仓库后安装依赖:
|
| 53 |
+
|
| 54 |
+
```bash
|
| 55 |
+
npm install
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
如果不确定当前机器缺什么,运行只读诊断:
|
| 59 |
+
|
| 60 |
+
```bash
|
| 61 |
+
npm run doctor:hf-space
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
`doctor:hf-space` 会检查 Node、npm、`hf` CLI、HF 登录状态、`node_modules`、git、Docker、本机 access 文件和可选远端 Space 配置。该命令不会写远端 Secret、不会重启 Space、不会打印 Secret 值。
|
| 65 |
|
| 66 |
## Space Variables
|
| 67 |
|
|
|
|
| 69 |
|
| 70 |
```dotenv
|
| 71 |
AGENT_STATE_BACKEND=memory
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb
|
| 73 |
APP_LOG_LEVEL=warn
|
| 74 |
```
|
| 75 |
|
| 76 |
`NEXT_PUBLIC_IMAGE_STORAGE_MODE` 是构建期和运行期都需要的值。Dockerfile 已声明 build arg,Hugging Face Docker Space 会把同名 Variable 作为 build arg 传入构建,并在运行期注入环境变量。
|
| 77 |
|
| 78 |
+
如果不使用 `memory`,再按实际状态后端追加可选变量:
|
| 79 |
+
|
| 80 |
+
```dotenv
|
| 81 |
+
AGENT_SQLITE_PATH=generated-images/.agent-state/agent.sqlite
|
| 82 |
+
AGENT_DATABASE_URL=postgres://...
|
| 83 |
+
AGENT_DB_PASSWORD=<database-password>
|
| 84 |
+
AGENT_DB_PASSWORD_FILE=/path/to/password-file
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
`AGENT_DATABASE_URL`、`AGENT_DB_PASSWORD` 和 `AGENT_DB_PASSWORD_FILE` 是 PostgreSQL 配置路径,不需要在 `memory` 模式下设置为空值。
|
| 88 |
+
|
| 89 |
可选:
|
| 90 |
|
| 91 |
```dotenv
|
| 92 |
AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
|
| 93 |
```
|
| 94 |
|
| 95 |
+
`AGENT_PUBLIC_BASE_URL` 只影响 OpenAPI `servers[0].url`,必须填写绝对 `http`/`https` URL,不能包含凭据、查询参数或片段;Agent skill 仍应以 `GPT_IMAGE_PLAYGROUND_URL` 指向实际 Space 地址。
|
| 96 |
|
| 97 |
## Space Secrets
|
| 98 |
|
|
|
|
| 101 |
```dotenv
|
| 102 |
OPENAI_API_KEY=<your-api-key>
|
| 103 |
OPENAI_API_BASE_URL=https://api.openai.com/v1
|
| 104 |
+
APP_PASSWORD=<page-access-code>
|
| 105 |
AGENT_API_TOKEN=<long-random-agent-token>
|
| 106 |
```
|
| 107 |
|
| 108 |
+
公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
|
| 109 |
|
| 110 |
如果使用服务端渠道池,改用 `OPENAI_CHANNEL_N_*` Secrets:
|
| 111 |
|
|
|
|
| 119 |
## 手机网页使用
|
| 120 |
|
| 121 |
1. 打开 Space 地址,例如 `https://<user>-<space>.hf.space`。
|
| 122 |
+
2. 如果配置了 `APP_PASSWORD`,输入页面访问码。
|
| 123 |
3. 直接填写提示词并生图。若 Space 没有配置服务端 API Key,也可以在右上角 `API 设置` 中填写自己的 API Key 和 API URL。
|
| 124 |
4. `NEXT_PUBLIC_IMAGE_STORAGE_MODE=indexeddb` 时,图片结果保存在当前浏览器 IndexedDB。换设备、清理浏览器数据或隐私模式退出后,本地历史可能消失。
|
| 125 |
|
|
|
|
| 140 |
GPT_IMAGE_PLAYGROUND_URL=https://<user>-<space>.hf.space \
|
| 141 |
GPT_IMAGE_AGENT_TOKEN=<agent-token> \
|
| 142 |
node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
| 143 |
+
--allow-billable \
|
| 144 |
"a product photo of a ceramic mug on a wooden table"
|
| 145 |
```
|
| 146 |
|
|
|
|
| 195 |
|
| 196 |
注意:keepalive 是免费层的 best-effort 机制,不能保证绕过 Hugging Face 平台维护、重启或政策限制。若需要平台级保证,应升级到付费硬件并设置永不休眠。
|
| 197 |
|
| 198 |
+
## 初始化本机访问记录
|
| 199 |
+
|
| 200 |
+
不同用户首次接手自己的 Space 时,先在本机生成访问记录文件。该文件保存在用户 home 目录下,不应提交到��库:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
npm run init-access:hf-space -- \
|
| 204 |
+
--space-id <namespace>/<space-name> \
|
| 205 |
+
--space-url https://<user>-<space>.hf.space
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
默认写入:
|
| 209 |
+
|
| 210 |
+
```text
|
| 211 |
+
~/.cache/gpt-image-playground-customer/hf-space-access.txt
|
| 212 |
+
```
|
| 213 |
+
|
| 214 |
+
文件会包含:
|
| 215 |
+
|
| 216 |
+
```dotenv
|
| 217 |
+
HF_SPACE_ID=<namespace>/<space-name>
|
| 218 |
+
HF_SPACE_URL=https://<user>-<space>.hf.space
|
| 219 |
+
HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN
|
| 220 |
+
APP_PASSWORD=<generated-page-access-code>
|
| 221 |
+
AGENT_API_TOKEN=<generated-agent-token>
|
| 222 |
+
```
|
| 223 |
+
|
| 224 |
+
`HF_SPACE_URL` 必须是 Hugging Face 的 `https://*.hf.space` 纯 origin 地址,不能包含凭据、路径、查询参数或片段,也不能填写反向代理、自定义域名或普通示例域名。
|
| 225 |
+
|
| 226 |
+
脚本不会在输出中回显 `APP_PASSWORD` 或 `AGENT_API_TOKEN`。如果文件已存在,默认拒绝覆盖;确认要重置时使用:
|
| 227 |
+
|
| 228 |
+
```bash
|
| 229 |
+
npm run init-access:hf-space -- \
|
| 230 |
+
--space-id <namespace>/<space-name> \
|
| 231 |
+
--space-url https://<user>-<space>.hf.space \
|
| 232 |
+
--force
|
| 233 |
+
```
|
| 234 |
+
|
| 235 |
+
创建这个 txt 文件不需要 Hugging Face 账号密码。它只保存本项目的访问码、Agent token 和 Space 目标信息。
|
| 236 |
+
|
| 237 |
+
同步 Secret 到远端 Space 时,需要本机 `hf` CLI 已登录有目标 Space 管理权限的 Hugging Face Access Token。先检查登录状态:
|
| 238 |
+
|
| 239 |
+
```bash
|
| 240 |
+
hf auth whoami
|
| 241 |
+
```
|
| 242 |
+
|
| 243 |
+
如果未登录,执行:
|
| 244 |
+
|
| 245 |
+
```bash
|
| 246 |
+
hf auth login
|
| 247 |
+
```
|
| 248 |
+
|
| 249 |
+
`hf auth login` 使用的是 Hugging Face Access Token,不是账号密码。不要把 HF 账号密码或 HF Access Token 写入 `hf-space-access.txt`。
|
| 250 |
+
|
| 251 |
+
生成后可先做本机只读诊断,不写远端:
|
| 252 |
+
|
| 253 |
+
```bash
|
| 254 |
+
npm run doctor:hf-space -- --skip-remote
|
| 255 |
+
```
|
| 256 |
+
|
| 257 |
+
如果诊断提示 access 文件缺少 `HF_SPACE_ID`、`HF_SPACE_URL` 或 `HF_SPACE_SECRET_KEYS`,说明本机可能已有旧格式文件。可手工补齐这些字段,或确认重置后重新生成:
|
| 258 |
|
| 259 |
+
```bash
|
| 260 |
+
npm run init-access:hf-space -- \
|
| 261 |
+
--space-id <namespace>/<space-name> \
|
| 262 |
+
--space-url https://<user>-<space>.hf.space \
|
| 263 |
+
--force
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
## 同步本机访问码到 Space
|
| 267 |
+
|
| 268 |
+
如果本机访问记录文件里的 `APP_PASSWORD` 已更新,可以用脚本同步到 HF Space Secret、重启服务并验证新访问码:
|
| 269 |
|
| 270 |
```bash
|
| 271 |
npm run sync-secret:hf-space
|
|
|
|
| 277 |
~/.cache/gpt-image-playground-customer/hf-space-access.txt
|
| 278 |
```
|
| 279 |
|
| 280 |
+
由 `init-access:hf-space` 生成的文件会让同步脚本同时同步 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。旧格式文件默认只同步 `APP_PASSWORD`,不会在输出中回显访问码值。可通过环境变量覆盖目标或同步多个 key:
|
| 281 |
|
| 282 |
```bash
|
| 283 |
HF_SPACE_ID=misonL/gpt-image-playground-customer \
|
|
|
|
| 287 |
npm run sync-secret:hf-space
|
| 288 |
```
|
| 289 |
|
| 290 |
+
同步脚本默认要求 `HF_SPACE_ID` 和 `HF_SPACE_URL` 来自 access 文件或环境变量,避免不同用户误写到仓库示例 Space。只有维护默认示例 Space 时才使用:
|
| 291 |
+
|
| 292 |
+
```bash
|
| 293 |
+
npm run sync-secret:hf-space -- --use-default-target
|
| 294 |
+
```
|
| 295 |
+
|
| 296 |
可选参数:
|
| 297 |
|
| 298 |
- `npm run sync-secret:hf-space -- --no-restart`:只写 Secret,不重启 Space。
|
| 299 |
+
- `npm run sync-secret:hf-space -- --skip-verify`:跳过 `/api/auth-verify` 访问码验证。
|
| 300 |
+
- `npm run sync-secret:hf-space -- --use-default-target`:允许使用脚本内置默认 Space 目标。
|
| 301 |
|
| 302 |
## 验证门禁
|
| 303 |
|
|
|
|
| 306 |
```bash
|
| 307 |
npm test
|
| 308 |
npm run lint
|
| 309 |
+
npm run lint:scripts
|
| 310 |
npm run build
|
| 311 |
npm run keepalive:hf-space
|
| 312 |
npm run smoke:hf-space
|
docs/superpowers/plans/2026-05-15-image-download-share.md
CHANGED
|
@@ -25,10 +25,10 @@
|
|
| 25 |
- 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。
|
| 26 |
|
| 27 |
**护栏指标:**
|
| 28 |
-
- 不得把受
|
| 29 |
-
- 不得把 API Key、
|
| 30 |
- 不得破坏 Agent 产物内容路由。
|
| 31 |
-
- 不得破坏 `http://localhost:4783` 下无
|
| 32 |
- 不得静默回退到 mock 图片或伪造分享成功。
|
| 33 |
|
| 34 |
**采样计划:**
|
|
@@ -49,7 +49,7 @@
|
|
| 49 |
|
| 50 |
**约束:**
|
| 51 |
- 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。
|
| 52 |
-
- 当前脏工作区包含
|
| 53 |
- 保持当前 `node:test` 布局;不要引入第二套测试框架。
|
| 54 |
- 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。
|
| 55 |
|
|
@@ -61,7 +61,7 @@
|
|
| 61 |
|
| 62 |
**耦合说明:**
|
| 63 |
- 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。
|
| 64 |
-
- 分享内容必须独立于页面
|
| 65 |
- 下载是纯浏览器行为,不应要求新增服务端状态。
|
| 66 |
- 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。
|
| 67 |
- 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。
|
|
@@ -102,7 +102,7 @@
|
|
| 102 |
| --- | --- |
|
| 103 |
| 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 |
|
| 104 |
| 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 |
|
| 105 |
-
| 收益 | 外部分享访问不再依赖页面
|
| 106 |
| 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 |
|
| 107 |
| 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 |
|
| 108 |
|
|
@@ -112,7 +112,7 @@
|
|
| 112 |
- `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。
|
| 113 |
- `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。
|
| 114 |
- `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。
|
| 115 |
-
- `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面
|
| 116 |
- `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。
|
| 117 |
- `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。
|
| 118 |
- `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。
|
|
@@ -162,7 +162,7 @@
|
|
| 162 |
| --- | --- | --- | --- |
|
| 163 |
| 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 |
|
| 164 |
| 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 |
|
| 165 |
-
| 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面
|
| 166 |
| 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 |
|
| 167 |
|
| 168 |
## 状态模型
|
|
@@ -231,7 +231,7 @@ afterEach(async () => {
|
|
| 231 |
|
| 232 |
function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) {
|
| 233 |
const headers = new Headers();
|
| 234 |
-
const accessToken = options.accessToken === undefined ? createAccessToken(['customer', '
|
| 235 |
if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`);
|
| 236 |
return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form });
|
| 237 |
}
|
|
@@ -239,7 +239,7 @@ function createShareRequest(form: FormData, options: { accessToken?: string | nu
|
|
| 239 |
describe('POST /api/shares', () => {
|
| 240 |
it('creates a share from an uploaded image without returning secrets', async () => {
|
| 241 |
await withTempCwd();
|
| 242 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 243 |
const form = new FormData();
|
| 244 |
form.set('sourceFilename', 'result.png');
|
| 245 |
form.set('accessCode', '12345678');
|
|
@@ -258,9 +258,9 @@ describe('POST /api/shares', () => {
|
|
| 258 |
assert.equal('accessCodeSalt' in body, false);
|
| 259 |
});
|
| 260 |
|
| 261 |
-
it('rejects unauthenticated share creation when a page
|
| 262 |
await withTempCwd();
|
| 263 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 264 |
const form = new FormData();
|
| 265 |
form.set('sourceFilename', 'result.png');
|
| 266 |
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
|
@@ -273,7 +273,7 @@ describe('POST /api/shares', () => {
|
|
| 273 |
|
| 274 |
it('rejects share creation with an invalid page access token', async () => {
|
| 275 |
await withTempCwd();
|
| 276 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 277 |
const form = new FormData();
|
| 278 |
form.set('sourceFilename', 'result.png');
|
| 279 |
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
|
@@ -284,7 +284,7 @@ describe('POST /api/shares', () => {
|
|
| 284 |
assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
|
| 285 |
});
|
| 286 |
|
| 287 |
-
it('allows share creation when no page
|
| 288 |
await withTempCwd();
|
| 289 |
delete process.env.APP_PASSWORD;
|
| 290 |
const form = new FormData();
|
|
@@ -297,7 +297,7 @@ describe('POST /api/shares', () => {
|
|
| 297 |
|
| 298 |
it('treats blank access codes as public shares', async () => {
|
| 299 |
await withTempCwd();
|
| 300 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 301 |
const form = new FormData();
|
| 302 |
form.set('sourceFilename', 'result.png');
|
| 303 |
form.set('accessCode', ' ');
|
|
@@ -311,7 +311,7 @@ describe('POST /api/shares', () => {
|
|
| 311 |
|
| 312 |
it('rejects short access codes', async () => {
|
| 313 |
await withTempCwd();
|
| 314 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 315 |
const form = new FormData();
|
| 316 |
form.set('sourceFilename', 'result.png');
|
| 317 |
form.set('accessCode', '1234567');
|
|
@@ -325,7 +325,7 @@ describe('POST /api/shares', () => {
|
|
| 325 |
|
| 326 |
it('rejects missing image uploads', async () => {
|
| 327 |
await withTempCwd();
|
| 328 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 329 |
const form = new FormData();
|
| 330 |
form.set('sourceFilename', 'result.png');
|
| 331 |
|
|
@@ -337,7 +337,7 @@ describe('POST /api/shares', () => {
|
|
| 337 |
|
| 338 |
it('rejects invalid expiry values', async () => {
|
| 339 |
await withTempCwd();
|
| 340 |
-
process.env.APP_PASSWORD = ['customer', '
|
| 341 |
const form = new FormData();
|
| 342 |
form.set('sourceFilename', 'result.png');
|
| 343 |
form.set('expiresInMinutes', '-1');
|
|
@@ -1338,7 +1338,7 @@ onShareImage={handleOpenShareImage}
|
|
| 1338 |
'share.copyLink': '复制分享链接',
|
| 1339 |
'share.create': '创建分享',
|
| 1340 |
'share.createFailed': '创建分享失败。',
|
| 1341 |
-
'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入
|
| 1342 |
```
|
| 1343 |
|
| 1344 |
英文:
|
|
@@ -1359,7 +1359,7 @@ onShareImage={handleOpenShareImage}
|
|
| 1359 |
'share.copyLink': 'Copy share link',
|
| 1360 |
'share.create': 'Create Share',
|
| 1361 |
'share.createFailed': 'Failed to create share.',
|
| 1362 |
-
'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the
|
| 1363 |
```
|
| 1364 |
|
| 1365 |
- [x] **步骤 5:运行前端验证**
|
|
|
|
| 25 |
- 最终验证运行 `npm test`、`npm run lint`、`npm run build`、`git diff --check`;如果本分支完成实现,还要执行 Docker 冒烟验证。
|
| 26 |
|
| 27 |
**护栏指标:**
|
| 28 |
+
- 不得把受访问码保护的 `/api/image/{filename}` 直接暴露为分享机制。
|
| 29 |
+
- 不得把 API Key、访问码或原始提示词写入分享 URL。
|
| 30 |
- 不得破坏 Agent 产物内容路由。
|
| 31 |
+
- 不得破坏 `http://localhost:4783` 下无访问码本地部署的既有行为。
|
| 32 |
- 不得静默回退到 mock 图片或伪造分享成功。
|
| 33 |
|
| 34 |
**采样计划:**
|
|
|
|
| 49 |
|
| 50 |
**约束:**
|
| 51 |
- 当前 `AGENTS.md` 要求中文沟通、基于事实下结论、禁止静默降级,并执行最小充分验证。
|
| 52 |
+
- 当前脏工作区包含访问码和 cookie 适配相关改动。不要回滚或覆盖无关的用户改动或前序代理改动。
|
| 53 |
- 保持当前 `node:test` 布局;不要引入第二套测试框架。
|
| 54 |
- 代码和文档尽量使用 ASCII;不使用 Emoji 或装饰性 Unicode。
|
| 55 |
|
|
|
|
| 61 |
|
| 62 |
**耦合说明:**
|
| 63 |
- 分享创建依赖发送到编辑所使用的同一图片字节读取路径:IndexedDB blob 或 `/api/image/{filename}`。
|
| 64 |
+
- 分享内容必须独立于页面访问码 cookie;否则外部接收者无法访问有效分享。
|
| 65 |
- 下载是纯浏览器行为,不应要求新增服务端状态。
|
| 66 |
- 分享 token 必须由 `crypto.randomBytes` 生成,不得使用 `Math.random`、时间戳或可预测输入派生。
|
| 67 |
- 访问码为空或纯空白时必须按“无访问码分享”处理;非空访问码必须满足最小长度,避免弱访问码被误认为受保护分享。
|
|
|
|
| 102 |
| --- | --- |
|
| 103 |
| 复杂性原位置 | 用户目前依赖受保护图片 URL 或本地浏览器 blob 做临时下载/分享。 |
|
| 104 |
| 新位置 | 分享产物移动到 `generated-images/.shares`,包含元数据、复制字节、访问码哈希和有效期。 |
|
| 105 |
+
| 收益 | 外部分享访问不再依赖页面访问码 cookie 或浏览器本地 IndexedDB 状态。 |
|
| 106 |
| 新成本 | 后续必须考虑分享清理和生命周期;分享元数据成为新的文件系统状态面。 |
|
| 107 |
| 失效模式 | 在后续新增清理任务前,孤立分享文件或过期分享可能持续积累。 |
|
| 108 |
|
|
|
|
| 112 |
- `src/lib/share-store.ts` 已实现分享元数据、复制内容、访问码哈希、有效期和路径限制。
|
| 113 |
- `src/lib/share-store.test.ts` 已覆盖存储模块,包括受保护/公开分享、有效期、不安全 token、当前工作目录和内容路径限制。
|
| 114 |
- `src/lib/server-runtime.ts` 已导出 `createAccessToken(serverPassword)` 和 `verifyAccessToken(clientAccessToken, serverPassword)`。
|
| 115 |
+
- `src/lib/page-password-auth.ts` 已导出 `PAGE_PASSWORD_AUTH_ERROR_CODES.missing` 和 `.invalid`,对应页面访问码错误码。
|
| 116 |
- `src/components/image-output.tsx` 当前导入 `Grid`、`Loader2`、`Send`、`Terminal` 和 `Trash2`;没有下载/分享图标或 props。
|
| 117 |
- `src/components/image-output.tsx` 的动作行当前只渲染轮播控制、日志和发送到编辑。
|
| 118 |
- `src/components/image-output.tsx` 当前已有 `isSingleImageView`,定义为 `typeof viewMode === 'number'`。
|
|
|
|
| 162 |
| --- | --- | --- | --- |
|
| 163 |
| 使用浏览器 blob URL 添加下载按钮 | 用户可以保存已选图片 | 提升本地导出可用性 | blob 来源错误可能下载到过期或缺失图片 |
|
| 164 |
| 添加分享创建 API | 用户可以创建分享 URL | 提升外部分享能力 | 如果认证绕过不当,可能暴露受保护图片字节 |
|
| 165 |
+
| 添加分享内容路由 | 接收者可以查看有效分享 | 启用公开读取路径 | 不得依赖页面访问码 cookie |
|
| 166 |
| 添加访问码和有效期检查 | 无效接收者无法查看字节 | 降低未授权暴露 | 如果元数据和内容结果不一致,UI 可能变得困惑 |
|
| 167 |
|
| 168 |
## 状态模型
|
|
|
|
| 231 |
|
| 232 |
function createShareRequest(form: FormData, options: { accessToken?: string | null } = {}) {
|
| 233 |
const headers = new Headers();
|
| 234 |
+
const accessToken = options.accessToken === undefined ? createAccessToken(['customer', 'access', 'code'].join('-')) : options.accessToken;
|
| 235 |
if (accessToken) headers.set('Cookie', `gptImageAccess=${accessToken}`);
|
| 236 |
return new NextRequest('http://localhost/api/shares', { method: 'POST', headers, body: form });
|
| 237 |
}
|
|
|
|
| 239 |
describe('POST /api/shares', () => {
|
| 240 |
it('creates a share from an uploaded image without returning secrets', async () => {
|
| 241 |
await withTempCwd();
|
| 242 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 243 |
const form = new FormData();
|
| 244 |
form.set('sourceFilename', 'result.png');
|
| 245 |
form.set('accessCode', '12345678');
|
|
|
|
| 258 |
assert.equal('accessCodeSalt' in body, false);
|
| 259 |
});
|
| 260 |
|
| 261 |
+
it('rejects unauthenticated share creation when a page access code is configured', async () => {
|
| 262 |
await withTempCwd();
|
| 263 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 264 |
const form = new FormData();
|
| 265 |
form.set('sourceFilename', 'result.png');
|
| 266 |
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
|
|
|
| 273 |
|
| 274 |
it('rejects share creation with an invalid page access token', async () => {
|
| 275 |
await withTempCwd();
|
| 276 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 277 |
const form = new FormData();
|
| 278 |
form.set('sourceFilename', 'result.png');
|
| 279 |
form.set('image', new File([new Uint8Array([1])], 'result.png', { type: 'image/png' }));
|
|
|
|
| 284 |
assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
|
| 285 |
});
|
| 286 |
|
| 287 |
+
it('allows share creation when no page access code is configured', async () => {
|
| 288 |
await withTempCwd();
|
| 289 |
delete process.env.APP_PASSWORD;
|
| 290 |
const form = new FormData();
|
|
|
|
| 297 |
|
| 298 |
it('treats blank access codes as public shares', async () => {
|
| 299 |
await withTempCwd();
|
| 300 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 301 |
const form = new FormData();
|
| 302 |
form.set('sourceFilename', 'result.png');
|
| 303 |
form.set('accessCode', ' ');
|
|
|
|
| 311 |
|
| 312 |
it('rejects short access codes', async () => {
|
| 313 |
await withTempCwd();
|
| 314 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 315 |
const form = new FormData();
|
| 316 |
form.set('sourceFilename', 'result.png');
|
| 317 |
form.set('accessCode', '1234567');
|
|
|
|
| 325 |
|
| 326 |
it('rejects missing image uploads', async () => {
|
| 327 |
await withTempCwd();
|
| 328 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 329 |
const form = new FormData();
|
| 330 |
form.set('sourceFilename', 'result.png');
|
| 331 |
|
|
|
|
| 337 |
|
| 338 |
it('rejects invalid expiry values', async () => {
|
| 339 |
await withTempCwd();
|
| 340 |
+
process.env.APP_PASSWORD = ['customer', 'access', 'code'].join('-');
|
| 341 |
const form = new FormData();
|
| 342 |
form.set('sourceFilename', 'result.png');
|
| 343 |
form.set('expiresInMinutes', '-1');
|
|
|
|
| 1338 |
'share.copyLink': '复制分享链接',
|
| 1339 |
'share.create': '创建分享',
|
| 1340 |
'share.createFailed': '创建分享失败。',
|
| 1341 |
+
'error.imageAccessRefreshFailed': '无法刷新图片访问权限,请重新输入访问码后再试。',
|
| 1342 |
```
|
| 1343 |
|
| 1344 |
英文:
|
|
|
|
| 1359 |
'share.copyLink': 'Copy share link',
|
| 1360 |
'share.create': 'Create Share',
|
| 1361 |
'share.createFailed': 'Failed to create share.',
|
| 1362 |
+
'error.imageAccessRefreshFailed': 'Unable to refresh image access. Enter the access code again and retry.',
|
| 1363 |
```
|
| 1364 |
|
| 1365 |
- [x] **步骤 5:运行前端验证**
|
package.json
CHANGED
|
@@ -7,13 +7,17 @@
|
|
| 7 |
"prebuild": "node scripts/clean-standalone.mjs",
|
| 8 |
"build": "next build",
|
| 9 |
"postbuild": "node scripts/patch-standalone-runtime.mjs",
|
| 10 |
-
"test": "node --test --import tsx \"src/**/*.test.ts\"",
|
|
|
|
| 11 |
"test:postgres": "node scripts/test-postgres-live.mjs",
|
|
|
|
|
|
|
| 12 |
"keepalive:hf-space": "node scripts/keepalive-hf-space.mjs",
|
| 13 |
"sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs",
|
| 14 |
"smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs",
|
| 15 |
"start": "node scripts/start-standalone.mjs",
|
| 16 |
"lint": "eslint src",
|
|
|
|
| 17 |
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
| 18 |
},
|
| 19 |
"dependencies": {
|
|
|
|
| 7 |
"prebuild": "node scripts/clean-standalone.mjs",
|
| 8 |
"build": "next build",
|
| 9 |
"postbuild": "node scripts/patch-standalone-runtime.mjs",
|
| 10 |
+
"test": "node --test --import tsx \"src/**/*.test.ts\" \"scripts/**/*.test.mjs\"",
|
| 11 |
+
"test:scripts": "node --test \"scripts/**/*.test.mjs\"",
|
| 12 |
"test:postgres": "node scripts/test-postgres-live.mjs",
|
| 13 |
+
"doctor:hf-space": "node scripts/doctor-hf-space.mjs",
|
| 14 |
+
"init-access:hf-space": "node scripts/init-hf-space-access.mjs",
|
| 15 |
"keepalive:hf-space": "node scripts/keepalive-hf-space.mjs",
|
| 16 |
"sync-secret:hf-space": "node scripts/sync-hf-space-secret.mjs",
|
| 17 |
"smoke:hf-space": "node scripts/smoke-hf-space-memory.mjs",
|
| 18 |
"start": "node scripts/start-standalone.mjs",
|
| 19 |
"lint": "eslint src",
|
| 20 |
+
"lint:scripts": "node scripts/check-node-syntax.mjs",
|
| 21 |
"format": "prettier --write \"src/**/*.{ts,tsx}\""
|
| 22 |
},
|
| 23 |
"dependencies": {
|
scripts/agent-skill-scripts.test.mjs
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawn, spawnSync } from 'node:child_process';
|
| 3 |
+
import { createServer } from 'node:http';
|
| 4 |
+
import { fileURLToPath } from 'node:url';
|
| 5 |
+
import { join } from 'node:path';
|
| 6 |
+
import { describe, it } from 'node:test';
|
| 7 |
+
import { parseRetryAfterValue, resolveSameOriginUrl } from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
|
| 8 |
+
|
| 9 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 10 |
+
const skillScriptsRoot = join(repoRoot, 'skills/gpt-image-playground-agent/scripts');
|
| 11 |
+
|
| 12 |
+
describe('Agent skill script argument validation', () => {
|
| 13 |
+
it('rejects invalid generate numeric options before dry-run output', () => {
|
| 14 |
+
const result = runSkillScript('generate-image.mjs', ['--n', 'abc', 'prompt']);
|
| 15 |
+
|
| 16 |
+
assert.equal(result.status, 2);
|
| 17 |
+
assert.match(result.stderr, /--n 必须是正整数/);
|
| 18 |
+
assert.equal(result.stdout.trim(), '');
|
| 19 |
+
});
|
| 20 |
+
|
| 21 |
+
it('rejects invalid edit timeout before reading the image path', () => {
|
| 22 |
+
const result = runSkillScript('edit-image.mjs', ['--timeout-ms', 'abc', '/tmp/missing.png', 'prompt']);
|
| 23 |
+
|
| 24 |
+
assert.equal(result.status, 2);
|
| 25 |
+
assert.match(result.stderr, /--timeout-ms 必须是正整数/);
|
| 26 |
+
assert.equal(result.stdout.trim(), '');
|
| 27 |
+
});
|
| 28 |
+
|
| 29 |
+
it('rejects invalid upstream probe timeout before network checks', () => {
|
| 30 |
+
const result = runSkillScript('probe-upstream-image.mjs', ['--timeout-ms', 'abc']);
|
| 31 |
+
|
| 32 |
+
assert.equal(result.status, 2);
|
| 33 |
+
assert.match(result.stderr, /--timeout-ms 必须是正整数/);
|
| 34 |
+
assert.equal(result.stdout.trim(), '');
|
| 35 |
+
});
|
| 36 |
+
|
| 37 |
+
it('rejects invalid retry attempt env values', () => {
|
| 38 |
+
const result = runSkillScript('generate-image.mjs', ['prompt'], {
|
| 39 |
+
GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc'
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
assert.equal(result.status, 2);
|
| 43 |
+
assert.match(result.stderr, /GPT_IMAGE_AGENT_MAX_ATTEMPTS 必须是正整数/);
|
| 44 |
+
assert.equal(result.stdout.trim(), '');
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('rejects service base URLs with embedded credentials before dry-run output', () => {
|
| 48 |
+
const result = runSkillScript('generate-image.mjs', ['prompt'], {
|
| 49 |
+
GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test'
|
| 50 |
+
});
|
| 51 |
+
|
| 52 |
+
assert.equal(result.status, 2);
|
| 53 |
+
assert.match(result.stderr, /base URL/);
|
| 54 |
+
assert.match(result.stderr, /不能包含凭据/);
|
| 55 |
+
assert.doesNotMatch(result.stderr, /secret/);
|
| 56 |
+
assert.equal(result.stdout.trim(), '');
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
it('rejects upstream probe base URLs with embedded credentials before network checks', () => {
|
| 60 |
+
const result = runSkillScript('probe-upstream-image.mjs', ['--base-url', 'https://user:secret@example.test/v1']);
|
| 61 |
+
|
| 62 |
+
assert.equal(result.status, 2);
|
| 63 |
+
assert.match(result.stderr, /base URL/);
|
| 64 |
+
assert.match(result.stderr, /不能包含凭据/);
|
| 65 |
+
assert.doesNotMatch(result.stderr, /secret/);
|
| 66 |
+
assert.equal(result.stdout.trim(), '');
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it('does not read prompt files during default generate dry-run', () => {
|
| 70 |
+
const result = runSkillScript('generate-image.mjs', ['--prompt-file', '/tmp/missing-agent-prompt.txt']);
|
| 71 |
+
|
| 72 |
+
assert.equal(result.status, 0);
|
| 73 |
+
const body = JSON.parse(result.stdout);
|
| 74 |
+
assert.equal(body.dry_run, true);
|
| 75 |
+
assert.equal(body.billable, false);
|
| 76 |
+
assert.equal(result.stderr.trim(), '');
|
| 77 |
+
});
|
| 78 |
+
|
| 79 |
+
it('shows edit help without validating unrelated env values', () => {
|
| 80 |
+
const result = runSkillScript('edit-image.mjs', ['--help'], {
|
| 81 |
+
GPT_IMAGE_AGENT_MAX_ATTEMPTS: 'abc'
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
assert.equal(result.status, 0);
|
| 85 |
+
assert.match(result.stderr, /用法:edit-image\.mjs/);
|
| 86 |
+
assert.equal(result.stdout.trim(), '');
|
| 87 |
+
});
|
| 88 |
+
|
| 89 |
+
it('shows skill script help without validating service URL env values', () => {
|
| 90 |
+
const env = { GPT_IMAGE_PLAYGROUND_URL: 'https://user:secret@example.test' };
|
| 91 |
+
const generateHelp = runSkillScript('generate-image.mjs', ['--help'], env);
|
| 92 |
+
const editHelp = runSkillScript('edit-image.mjs', ['--help'], env);
|
| 93 |
+
|
| 94 |
+
assert.equal(generateHelp.status, 0);
|
| 95 |
+
assert.match(generateHelp.stderr, /用法:generate-image\.mjs/);
|
| 96 |
+
assert.equal(generateHelp.stdout.trim(), '');
|
| 97 |
+
assert.equal(editHelp.status, 0);
|
| 98 |
+
assert.match(editHelp.stderr, /用法:edit-image\.mjs/);
|
| 99 |
+
assert.equal(editHelp.stdout.trim(), '');
|
| 100 |
+
});
|
| 101 |
+
|
| 102 |
+
it('rejects cross-origin job result URLs before sending auth headers', () => {
|
| 103 |
+
assert.throws(
|
| 104 |
+
() => resolveSameOriginUrl('https://space.example.test', 'https://evil.example.test/result', 'job.result_url'),
|
| 105 |
+
/不同 origin/
|
| 106 |
+
);
|
| 107 |
+
assert.equal(
|
| 108 |
+
resolveSameOriginUrl('https://space.example.test', '/api/agent/jobs/abc/result', 'job.result_url'),
|
| 109 |
+
'https://space.example.test/api/agent/jobs/abc/result'
|
| 110 |
+
);
|
| 111 |
+
});
|
| 112 |
+
|
| 113 |
+
it('caps retry-after values before sleeping', () => {
|
| 114 |
+
assert.equal(parseRetryAfterValue('5'), 5);
|
| 115 |
+
assert.equal(parseRetryAfterValue('0'), 1);
|
| 116 |
+
assert.equal(parseRetryAfterValue('999999999999999999999'), 60);
|
| 117 |
+
assert.equal(parseRetryAfterValue('not-a-number', 7), 7);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it('preserves non-JSON capabilities status and body in generate failures', async () => {
|
| 121 |
+
await withServer(
|
| 122 |
+
(request, response) => {
|
| 123 |
+
if (request.url === '/api/agent/capabilities') {
|
| 124 |
+
response.writeHead(503, { 'content-type': 'text/plain' });
|
| 125 |
+
response.end('maintenance window');
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
response.writeHead(404, { 'content-type': 'text/plain' });
|
| 129 |
+
response.end('missing');
|
| 130 |
+
},
|
| 131 |
+
async (baseUrl) => {
|
| 132 |
+
const result = await runSkillScriptAsync('generate-image.mjs', ['--allow-billable', 'prompt'], {
|
| 133 |
+
GPT_IMAGE_PLAYGROUND_URL: baseUrl
|
| 134 |
+
});
|
| 135 |
+
|
| 136 |
+
assert.equal(result.status, 1);
|
| 137 |
+
assert.match(result.stderr, /capabilities 请求失败,状态码 503:maintenance window/);
|
| 138 |
+
}
|
| 139 |
+
);
|
| 140 |
+
});
|
| 141 |
+
});
|
| 142 |
+
|
| 143 |
+
function runSkillScript(filename, args, env = {}) {
|
| 144 |
+
return spawnSync(process.execPath, [join(skillScriptsRoot, filename), ...args], {
|
| 145 |
+
cwd: repoRoot,
|
| 146 |
+
encoding: 'utf8',
|
| 147 |
+
env: { ...process.env, ...env }
|
| 148 |
+
});
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
function runSkillScriptAsync(filename, args, env = {}) {
|
| 152 |
+
return new Promise((resolve) => {
|
| 153 |
+
const child = spawn(process.execPath, [join(skillScriptsRoot, filename), ...args], {
|
| 154 |
+
cwd: repoRoot,
|
| 155 |
+
env: { ...process.env, ...env },
|
| 156 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 157 |
+
});
|
| 158 |
+
let stdout = '';
|
| 159 |
+
let stderr = '';
|
| 160 |
+
child.stdout.setEncoding('utf8');
|
| 161 |
+
child.stderr.setEncoding('utf8');
|
| 162 |
+
child.stdout.on('data', (chunk) => {
|
| 163 |
+
stdout += chunk;
|
| 164 |
+
});
|
| 165 |
+
child.stderr.on('data', (chunk) => {
|
| 166 |
+
stderr += chunk;
|
| 167 |
+
});
|
| 168 |
+
child.on('close', (status) => {
|
| 169 |
+
resolve({ status, stdout, stderr });
|
| 170 |
+
});
|
| 171 |
+
});
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
async function withServer(handler, run) {
|
| 175 |
+
const server = createServer(handler);
|
| 176 |
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 177 |
+
const address = server.address();
|
| 178 |
+
try {
|
| 179 |
+
assert.equal(typeof address, 'object');
|
| 180 |
+
assert.ok(address);
|
| 181 |
+
await run(`http://127.0.0.1:${address.port}`);
|
| 182 |
+
} finally {
|
| 183 |
+
await new Promise((resolve) => server.close(resolve));
|
| 184 |
+
}
|
| 185 |
+
}
|
scripts/check-node-syntax.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { spawnSync } from 'node:child_process';
|
| 4 |
+
import { readdirSync } from 'node:fs';
|
| 5 |
+
import { join, relative } from 'node:path';
|
| 6 |
+
import { fileURLToPath } from 'node:url';
|
| 7 |
+
|
| 8 |
+
const REPO_ROOT = fileURLToPath(new URL('..', import.meta.url));
|
| 9 |
+
const CHECK_DIRS = ['scripts', 'skills'];
|
| 10 |
+
|
| 11 |
+
function listMjsFiles() {
|
| 12 |
+
return CHECK_DIRS.flatMap((dir) => listMjsFilesUnder(join(REPO_ROOT, dir))).sort((a, b) => a.label.localeCompare(b.label));
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function listMjsFilesUnder(dir) {
|
| 16 |
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
| 17 |
+
const path = join(dir, entry.name);
|
| 18 |
+
if (entry.isDirectory()) return listMjsFilesUnder(path);
|
| 19 |
+
if (!entry.isFile() || !entry.name.endsWith('.mjs')) return [];
|
| 20 |
+
return {
|
| 21 |
+
path,
|
| 22 |
+
label: relative(REPO_ROOT, path).replaceAll('\\', '/')
|
| 23 |
+
};
|
| 24 |
+
});
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
let failed = false;
|
| 28 |
+
for (const file of listMjsFiles()) {
|
| 29 |
+
const result = spawnSync(process.execPath, ['--check', file.path], {
|
| 30 |
+
encoding: 'utf8',
|
| 31 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 32 |
+
});
|
| 33 |
+
if (result.status === 0) continue;
|
| 34 |
+
failed = true;
|
| 35 |
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 36 |
+
console.error(output || `Syntax check failed: ${file.label}`);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
if (failed) process.exit(1);
|
scripts/doctor-hf-space.mjs
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { existsSync, statSync } from 'node:fs';
|
| 4 |
+
import {
|
| 5 |
+
assertKnownOptions,
|
| 6 |
+
buildNextActions,
|
| 7 |
+
classifyRequiredAndRecommendedNames,
|
| 8 |
+
DEFAULT_ACCESS_FILE,
|
| 9 |
+
getJsonNames,
|
| 10 |
+
isMainModule,
|
| 11 |
+
missingKeys,
|
| 12 |
+
parseAccessFile,
|
| 13 |
+
readEnvValue,
|
| 14 |
+
readOptionValue,
|
| 15 |
+
runCommand,
|
| 16 |
+
validateSpaceId,
|
| 17 |
+
validateSpaceUrl
|
| 18 |
+
} from './hf-space-doctor-utils.mjs';
|
| 19 |
+
|
| 20 |
+
const MIN_NODE_MAJOR = 20;
|
| 21 |
+
const REQUIRED_ACCESS_KEYS = ['HF_SPACE_ID', 'HF_SPACE_URL', 'HF_SPACE_SECRET_KEYS', 'APP_PASSWORD', 'AGENT_API_TOKEN'];
|
| 22 |
+
const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD'];
|
| 23 |
+
const REQUIRED_SPACE_VARIABLES = ['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'];
|
| 24 |
+
const RECOMMENDED_SPACE_VARIABLES = ['APP_LOG_LEVEL'];
|
| 25 |
+
const REQUIRED_SPACE_SECRETS = ['APP_PASSWORD', 'AGENT_API_TOKEN'];
|
| 26 |
+
const OPTIONAL_GENERATION_SECRETS = ['OPENAI_API_KEY', 'OPENAI_CHANNEL_1_API_KEYS'];
|
| 27 |
+
|
| 28 |
+
function parseArgs(argv) {
|
| 29 |
+
assertKnownOptions(argv, ['--access-file', '--help', '-h', '--skip-remote']);
|
| 30 |
+
return {
|
| 31 |
+
accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE,
|
| 32 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 33 |
+
skipRemote: argv.includes('--skip-remote')
|
| 34 |
+
};
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function printHelp() {
|
| 38 |
+
console.log(`Usage:
|
| 39 |
+
npm run doctor:hf-space
|
| 40 |
+
|
| 41 |
+
Options:
|
| 42 |
+
--access-file <path> Override the access file path.
|
| 43 |
+
--skip-remote Skip read-only Hugging Face remote checks.
|
| 44 |
+
--help Show this help.
|
| 45 |
+
|
| 46 |
+
Environment overrides:
|
| 47 |
+
HF_SPACE_ACCESS_FILE`);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function addCheck(checks, status, name, message, details = {}) {
|
| 51 |
+
checks.push({ status, name, message, ...details });
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function checkNode(checks) {
|
| 55 |
+
const major = Number.parseInt(process.versions.node.split('.')[0], 10);
|
| 56 |
+
if (major >= MIN_NODE_MAJOR) {
|
| 57 |
+
addCheck(checks, 'pass', 'node', `Node.js ${process.version} is supported.`);
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
addCheck(checks, 'fail', 'node', `Node.js ${process.version} is too old. Install Node.js ${MIN_NODE_MAJOR} or newer.`);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function checkCommand(checks, name, command, args, failureAction) {
|
| 64 |
+
const result = runCommand(command, args);
|
| 65 |
+
if (result.ok) {
|
| 66 |
+
addCheck(checks, 'pass', name, `${command} is available.`, { version: result.stdout.split(/\r?\n/)[0] });
|
| 67 |
+
return true;
|
| 68 |
+
}
|
| 69 |
+
addCheck(checks, 'fail', name, failureAction, { error: result.error });
|
| 70 |
+
return false;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function checkAccessFile(checks, accessFile) {
|
| 74 |
+
if (!existsSync(accessFile)) {
|
| 75 |
+
addCheck(checks, 'fail', 'access-file', `Access file is missing: ${accessFile}`, {
|
| 76 |
+
action: 'Run npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space'
|
| 77 |
+
});
|
| 78 |
+
return undefined;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
let values;
|
| 82 |
+
try {
|
| 83 |
+
values = parseAccessFile(accessFile);
|
| 84 |
+
} catch (error) {
|
| 85 |
+
addCheck(checks, 'fail', 'access-file', 'Access file cannot be read.', {
|
| 86 |
+
error: error instanceof Error ? error.message : String(error)
|
| 87 |
+
});
|
| 88 |
+
return undefined;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
addCheck(checks, 'pass', 'access-file', `Access file exists: ${accessFile}`);
|
| 92 |
+
|
| 93 |
+
if (process.platform !== 'win32') {
|
| 94 |
+
const mode = statSync(accessFile).mode & 0o777;
|
| 95 |
+
if ((mode & 0o077) === 0) {
|
| 96 |
+
addCheck(checks, 'pass', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}.`);
|
| 97 |
+
} else {
|
| 98 |
+
addCheck(checks, 'fail', 'access-file-permissions', `Access file permissions are ${mode.toString(8)}; expected 600.`, {
|
| 99 |
+
action: `chmod 600 ${accessFile}`
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const missing = missingKeys(REQUIRED_ACCESS_KEYS, values);
|
| 105 |
+
if (missing.length) {
|
| 106 |
+
addCheck(checks, 'fail', 'access-file-keys', `Access file is missing required keys: ${missing.join(', ')}.`);
|
| 107 |
+
} else {
|
| 108 |
+
addCheck(checks, 'pass', 'access-file-keys', 'Access file contains all required non-empty keys.');
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => values.has(key));
|
| 112 |
+
if (forbidden.length) {
|
| 113 |
+
addCheck(checks, 'fail', 'access-file-forbidden-keys', `Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`);
|
| 114 |
+
} else {
|
| 115 |
+
addCheck(checks, 'pass', 'access-file-forbidden-keys', 'Access file does not contain Hugging Face account credentials.');
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
validateAccessValues(checks, values);
|
| 119 |
+
return values;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function validateAccessValues(checks, values) {
|
| 123 |
+
const spaceId = values.get('HF_SPACE_ID')?.trim();
|
| 124 |
+
const spaceIdError = validateSpaceId(spaceId);
|
| 125 |
+
if (!spaceIdError) {
|
| 126 |
+
addCheck(checks, 'pass', 'space-id', 'HF_SPACE_ID has namespace/space format.');
|
| 127 |
+
} else if (spaceId) {
|
| 128 |
+
addCheck(checks, 'fail', 'space-id', spaceIdError);
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
const spaceUrl = values.get('HF_SPACE_URL')?.trim();
|
| 132 |
+
if (spaceUrl) {
|
| 133 |
+
const spaceUrlError = validateSpaceUrl(spaceUrl);
|
| 134 |
+
if (spaceUrlError) {
|
| 135 |
+
addCheck(checks, 'fail', 'space-url', spaceUrlError);
|
| 136 |
+
} else {
|
| 137 |
+
addCheck(checks, 'pass', 'space-url', 'HF_SPACE_URL looks like a Hugging Face Space URL.');
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
const appPassword = values.get('APP_PASSWORD') || '';
|
| 142 |
+
const agentToken = values.get('AGENT_API_TOKEN') || '';
|
| 143 |
+
if (appPassword.length >= 16 && agentToken.length >= 24) {
|
| 144 |
+
addCheck(
|
| 145 |
+
checks,
|
| 146 |
+
'pass',
|
| 147 |
+
'generated-secrets',
|
| 148 |
+
'APP_PASSWORD access code and AGENT_API_TOKEN meet the minimum length checks.'
|
| 149 |
+
);
|
| 150 |
+
} else {
|
| 151 |
+
addCheck(
|
| 152 |
+
checks,
|
| 153 |
+
'fail',
|
| 154 |
+
'generated-secrets',
|
| 155 |
+
'APP_PASSWORD access code must be at least 16 chars and AGENT_API_TOKEN at least 24 chars.'
|
| 156 |
+
);
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function checkRemote(checks, values, skipRemote, hfAvailable, hfAuthenticated) {
|
| 161 |
+
if (skipRemote) {
|
| 162 |
+
addCheck(checks, 'skip', 'remote-space', 'Remote checks were skipped by --skip-remote.');
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
if (!hfAvailable || !hfAuthenticated || !values) {
|
| 166 |
+
addCheck(checks, 'skip', 'remote-space', 'Remote checks require hf CLI, hf auth login, and a valid access file.');
|
| 167 |
+
return;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
const spaceId = values.get('HF_SPACE_ID')?.trim();
|
| 171 |
+
if (!spaceId) {
|
| 172 |
+
addCheck(checks, 'skip', 'remote-space', 'Remote checks require HF_SPACE_ID in the access file.');
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
const info = runCommand('hf', ['spaces', 'info', spaceId, '--format', 'json']);
|
| 176 |
+
if (!info.ok) {
|
| 177 |
+
addCheck(checks, 'fail', 'remote-space', `Cannot read Space info for ${spaceId}.`, { error: info.error });
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
addCheck(checks, 'pass', 'remote-space', `Space ${spaceId} is accessible.`);
|
| 181 |
+
checkRemoteNames(
|
| 182 |
+
checks,
|
| 183 |
+
spaceId,
|
| 184 |
+
'remote-variables',
|
| 185 |
+
['spaces', 'variables', 'list', spaceId, '--json'],
|
| 186 |
+
REQUIRED_SPACE_VARIABLES,
|
| 187 |
+
RECOMMENDED_SPACE_VARIABLES
|
| 188 |
+
);
|
| 189 |
+
checkRemoteSecrets(checks, spaceId);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function checkRemoteNames(checks, spaceId, name, args, requiredNames, recommendedNames = []) {
|
| 193 |
+
const result = runCommand('hf', args);
|
| 194 |
+
if (!result.ok) {
|
| 195 |
+
addCheck(checks, 'warn', name, `Cannot list ${name} for ${spaceId}.`, { error: result.error });
|
| 196 |
+
return;
|
| 197 |
+
}
|
| 198 |
+
try {
|
| 199 |
+
const names = getJsonNames(result.stdout);
|
| 200 |
+
const { missingRequired, missingRecommended } = classifyRequiredAndRecommendedNames(
|
| 201 |
+
names,
|
| 202 |
+
requiredNames,
|
| 203 |
+
recommendedNames
|
| 204 |
+
);
|
| 205 |
+
if (missingRequired.length) {
|
| 206 |
+
addCheck(checks, 'fail', name, `${name} missing required names: ${missingRequired.join(', ')}.`);
|
| 207 |
+
} else {
|
| 208 |
+
addCheck(checks, 'pass', name, `${name} contains required names.`);
|
| 209 |
+
}
|
| 210 |
+
if (missingRecommended.length) {
|
| 211 |
+
addCheck(checks, 'warn', name, `${name} missing recommended names: ${missingRecommended.join(', ')}.`);
|
| 212 |
+
}
|
| 213 |
+
} catch (error) {
|
| 214 |
+
addCheck(checks, 'warn', name, `Cannot parse ${name} JSON output.`, {
|
| 215 |
+
error: error instanceof Error ? error.message : String(error)
|
| 216 |
+
});
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function checkRemoteSecrets(checks, spaceId) {
|
| 221 |
+
const result = runCommand('hf', ['spaces', 'secrets', 'list', spaceId, '--json']);
|
| 222 |
+
if (!result.ok) {
|
| 223 |
+
addCheck(checks, 'warn', 'remote-secrets', `Cannot list remote secrets for ${spaceId}.`, { error: result.error });
|
| 224 |
+
return;
|
| 225 |
+
}
|
| 226 |
+
try {
|
| 227 |
+
const names = getJsonNames(result.stdout);
|
| 228 |
+
const missing = REQUIRED_SPACE_SECRETS.filter((key) => !names.has(key));
|
| 229 |
+
const hasGenerationSecret = OPTIONAL_GENERATION_SECRETS.some((key) => names.has(key));
|
| 230 |
+
if (missing.length) {
|
| 231 |
+
addCheck(checks, 'fail', 'remote-secrets', `Remote secrets missing: ${missing.join(', ')}.`);
|
| 232 |
+
} else {
|
| 233 |
+
addCheck(checks, 'pass', 'remote-secrets', 'Remote secrets contain APP_PASSWORD and AGENT_API_TOKEN.');
|
| 234 |
+
}
|
| 235 |
+
if (hasGenerationSecret) {
|
| 236 |
+
addCheck(checks, 'pass', 'remote-generation-secret', 'Remote generation credential is configured.');
|
| 237 |
+
} else {
|
| 238 |
+
addCheck(checks, 'warn', 'remote-generation-secret', 'No OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS secret found; server-side generation may be unavailable.');
|
| 239 |
+
}
|
| 240 |
+
} catch (error) {
|
| 241 |
+
addCheck(checks, 'warn', 'remote-secrets', 'Cannot parse remote secrets JSON output.', {
|
| 242 |
+
error: error instanceof Error ? error.message : String(error)
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
function main() {
|
| 248 |
+
const options = parseArgs(process.argv.slice(2));
|
| 249 |
+
if (options.help) {
|
| 250 |
+
printHelp();
|
| 251 |
+
return;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const checks = [];
|
| 255 |
+
checkNode(checks);
|
| 256 |
+
checkCommand(checks, 'npm', 'npm', ['--version'], 'npm is missing. Install Node.js 20 or newer with npm.');
|
| 257 |
+
const hfAvailable = checkCommand(checks, 'hf-cli', 'hf', ['version'], 'hf CLI is missing. Install the Hugging Face CLI.');
|
| 258 |
+
const hfAuth = hfAvailable ? runCommand('hf', ['auth', 'whoami']) : { ok: false };
|
| 259 |
+
if (hfAvailable && hfAuth.ok) {
|
| 260 |
+
addCheck(checks, 'pass', 'hf-auth', 'hf CLI is authenticated.');
|
| 261 |
+
} else if (hfAvailable) {
|
| 262 |
+
addCheck(checks, 'fail', 'hf-auth', 'hf CLI auth check failed.', {
|
| 263 |
+
action: 'hf auth login',
|
| 264 |
+
error: hfAuth.error
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
if (existsSync('node_modules')) {
|
| 268 |
+
addCheck(checks, 'pass', 'node-modules', 'node_modules exists.');
|
| 269 |
+
} else {
|
| 270 |
+
addCheck(checks, 'warn', 'node-modules', 'node_modules is missing; build, lint, test, and smoke commands require npm install.');
|
| 271 |
+
}
|
| 272 |
+
checkCommand(checks, 'git', 'git', ['--version'], 'git is missing; install git before cloning or pushing Space repos.');
|
| 273 |
+
const docker = runCommand('docker', ['version', '--format', '{{.Server.Version}}']);
|
| 274 |
+
if (docker.ok) {
|
| 275 |
+
addCheck(checks, 'pass', 'docker', 'docker is available.', { version: docker.stdout.split(/\r?\n/)[0] });
|
| 276 |
+
} else {
|
| 277 |
+
addCheck(checks, 'warn', 'docker', 'Docker is unavailable; npm run smoke:hf-space will not work.', {
|
| 278 |
+
error: docker.error
|
| 279 |
+
});
|
| 280 |
+
const dockerCli = runCommand('docker', ['--version']);
|
| 281 |
+
if (dockerCli.ok) addCheck(checks, 'warn', 'docker-daemon', 'Docker CLI exists but the daemon is not reachable.');
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
const values = checkAccessFile(checks, options.accessFile);
|
| 285 |
+
checkRemote(checks, values, options.skipRemote, hfAvailable, Boolean(hfAuth.ok));
|
| 286 |
+
|
| 287 |
+
const failed = checks.some((check) => check.status === 'fail');
|
| 288 |
+
console.log(JSON.stringify({ ok: !failed, checks, nextActions: buildNextActions(checks) }, null, 2));
|
| 289 |
+
if (failed) process.exit(1);
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
try {
|
| 293 |
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
| 294 |
+
main();
|
| 295 |
+
}
|
| 296 |
+
} catch (error) {
|
| 297 |
+
console.error(JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) }, null, 2));
|
| 298 |
+
process.exit(1);
|
| 299 |
+
}
|
scripts/hf-space-doctor-utils.mjs
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { spawnSync } from 'node:child_process';
|
| 2 |
+
import { readFileSync } from 'node:fs';
|
| 3 |
+
import { join, resolve } from 'node:path';
|
| 4 |
+
import { pathToFileURL } from 'node:url';
|
| 5 |
+
|
| 6 |
+
export const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt');
|
| 7 |
+
|
| 8 |
+
export function readEnvValue(name) {
|
| 9 |
+
return process.env[name]?.trim() || undefined;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
export function readOptionValue(argv, name) {
|
| 13 |
+
const prefix = `${name}=`;
|
| 14 |
+
const inlineValue = argv.find((arg) => arg.startsWith(prefix));
|
| 15 |
+
if (inlineValue !== undefined) {
|
| 16 |
+
const value = inlineValue.slice(prefix.length).trim();
|
| 17 |
+
if (!value) throw new Error(`${name} requires a value`);
|
| 18 |
+
return value;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const index = argv.indexOf(name);
|
| 22 |
+
if (index === -1) return undefined;
|
| 23 |
+
const value = argv[index + 1]?.trim();
|
| 24 |
+
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`);
|
| 25 |
+
return value;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function assertKnownOptions(argv, knownOptions) {
|
| 29 |
+
const known = new Set(knownOptions);
|
| 30 |
+
for (const arg of argv) {
|
| 31 |
+
if (!arg.startsWith('-')) continue;
|
| 32 |
+
const name = arg.includes('=') ? arg.slice(0, arg.indexOf('=')) : arg;
|
| 33 |
+
if (!known.has(name)) throw new Error(`Unknown option: ${name}`);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function runCommand(command, args = []) {
|
| 38 |
+
const result = spawnSync(command, args, {
|
| 39 |
+
encoding: 'utf8',
|
| 40 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 41 |
+
});
|
| 42 |
+
if (result.error) return { ok: false, error: result.error.message };
|
| 43 |
+
if (result.status !== 0) {
|
| 44 |
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 45 |
+
return { ok: false, error: output || `${command} ${args.join(' ')} failed` };
|
| 46 |
+
}
|
| 47 |
+
return { ok: true, stdout: result.stdout.trim() };
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export function parseAccessFile(accessFile) {
|
| 51 |
+
const values = new Map();
|
| 52 |
+
const text = readFileSync(accessFile, 'utf8');
|
| 53 |
+
for (const rawLine of text.split(/\r?\n/)) {
|
| 54 |
+
if (!rawLine || rawLine.startsWith('#')) continue;
|
| 55 |
+
const separatorIndex = rawLine.indexOf('=');
|
| 56 |
+
if (separatorIndex <= 0) continue;
|
| 57 |
+
const key = rawLine.slice(0, separatorIndex).trim();
|
| 58 |
+
const value = rawLine.slice(separatorIndex + 1);
|
| 59 |
+
if (key) values.set(key, value);
|
| 60 |
+
}
|
| 61 |
+
return values;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export function getJsonNames(text) {
|
| 65 |
+
const parsed = JSON.parse(extractJsonPayload(text));
|
| 66 |
+
const names = new Set();
|
| 67 |
+
const visit = (value) => {
|
| 68 |
+
if (Array.isArray(value)) {
|
| 69 |
+
for (const item of value) visit(item);
|
| 70 |
+
return;
|
| 71 |
+
}
|
| 72 |
+
if (!value || typeof value !== 'object') return;
|
| 73 |
+
for (const key of ['name', 'key', 'id']) {
|
| 74 |
+
if (typeof value[key] === 'string') names.add(value[key]);
|
| 75 |
+
}
|
| 76 |
+
};
|
| 77 |
+
visit(parsed);
|
| 78 |
+
return names;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function extractJsonPayload(text) {
|
| 82 |
+
const trimmedText = text.trim();
|
| 83 |
+
if (!trimmedText) return '[]';
|
| 84 |
+
|
| 85 |
+
const lines = trimmedText.split(/\r?\n/);
|
| 86 |
+
const jsonLineIndex = lines.findIndex((line) => {
|
| 87 |
+
const trimmedLine = line.trimStart();
|
| 88 |
+
return trimmedLine.startsWith('{') || trimmedLine.startsWith('[');
|
| 89 |
+
});
|
| 90 |
+
if (jsonLineIndex === -1) return trimmedText;
|
| 91 |
+
return lines.slice(jsonLineIndex).join('\n');
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export function missingKeys(keys, values) {
|
| 95 |
+
return keys.filter((key) => !values.has(key) || !String(values.get(key)).trim());
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export function classifyRequiredAndRecommendedNames(names, requiredNames, recommendedNames = []) {
|
| 99 |
+
return {
|
| 100 |
+
missingRequired: requiredNames.filter((key) => !names.has(key)),
|
| 101 |
+
missingRecommended: recommendedNames.filter((key) => !names.has(key))
|
| 102 |
+
};
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
const NEXT_ACTIONS = new Map([
|
| 106 |
+
['node', 'Install Node.js 20 or newer, then reopen the terminal.'],
|
| 107 |
+
['npm', 'Install Node.js 20 or newer with npm.'],
|
| 108 |
+
['hf-cli', 'Install the Hugging Face CLI from the official documentation; avoid piping remote install scripts directly to a shell.'],
|
| 109 |
+
['hf-auth', 'Check network/proxy access to Hugging Face, then run hf auth login if the token is missing or expired.'],
|
| 110 |
+
['node-modules', 'Run npm install.'],
|
| 111 |
+
[
|
| 112 |
+
'access-file-keys',
|
| 113 |
+
'Regenerate or update the access file with npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space'
|
| 114 |
+
],
|
| 115 |
+
[
|
| 116 |
+
'generated-secrets',
|
| 117 |
+
'Regenerate weak or blank project secrets with npm run init-access:hf-space -- --space-id <namespace>/<space-name> --space-url https://<user>-<space>.hf.space --force'
|
| 118 |
+
],
|
| 119 |
+
['remote-variables', 'Configure the required and recommended Space Variables in Hugging Face Settings before syncing secrets.'],
|
| 120 |
+
['remote-secrets', 'Run npm run sync-secret:hf-space after hf auth login succeeds.'],
|
| 121 |
+
['remote-generation-secret', 'Configure OPENAI_API_KEY or OPENAI_CHANNEL_1_API_KEYS in Space Secrets before real image generation.']
|
| 122 |
+
]);
|
| 123 |
+
|
| 124 |
+
export function buildNextActions(checks) {
|
| 125 |
+
const actions = new Set();
|
| 126 |
+
for (const check of checks) {
|
| 127 |
+
if (check.status === 'pass' || check.status === 'skip') continue;
|
| 128 |
+
if (check.action) actions.add(check.action);
|
| 129 |
+
const mappedAction = NEXT_ACTIONS.get(check.name);
|
| 130 |
+
if (mappedAction) actions.add(mappedAction);
|
| 131 |
+
}
|
| 132 |
+
return [...actions];
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
export function validateSpaceId(spaceId) {
|
| 136 |
+
if (!spaceId?.trim()) return 'HF Space id is required.';
|
| 137 |
+
if (!/^[^/\s]+\/[^/\s]+$/.test(spaceId)) return 'HF Space id must use namespace/space format.';
|
| 138 |
+
return undefined;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
export function validateSpaceUrl(spaceUrl) {
|
| 142 |
+
if (!spaceUrl?.trim()) return 'HF Space URL is required.';
|
| 143 |
+
let parsedUrl;
|
| 144 |
+
try {
|
| 145 |
+
parsedUrl = new URL(spaceUrl);
|
| 146 |
+
} catch {
|
| 147 |
+
return 'HF Space URL is not a valid URL.';
|
| 148 |
+
}
|
| 149 |
+
if (parsedUrl.protocol !== 'https:') return 'HF Space URL must use https.';
|
| 150 |
+
if (!parsedUrl.hostname.endsWith('.hf.space')) return 'HF Space URL must be a Hugging Face .hf.space URL.';
|
| 151 |
+
if (parsedUrl.username || parsedUrl.password || parsedUrl.pathname !== '/' || parsedUrl.search || parsedUrl.hash) {
|
| 152 |
+
return 'HF Space URL must be a plain Space origin without credentials, path, query, or fragment.';
|
| 153 |
+
}
|
| 154 |
+
return undefined;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
export function assertSpaceTargetConfig({ spaceId, spaceUrl }) {
|
| 158 |
+
const spaceIdError = validateSpaceId(spaceId);
|
| 159 |
+
if (spaceIdError) throw new Error(spaceIdError);
|
| 160 |
+
const spaceUrlError = validateSpaceUrl(spaceUrl);
|
| 161 |
+
if (spaceUrlError) throw new Error(spaceUrlError);
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
export function isMainModule(moduleUrl, argvPath) {
|
| 165 |
+
return Boolean(argvPath) && moduleUrl === pathToFileURL(resolve(argvPath)).href;
|
| 166 |
+
}
|
scripts/hf-space-doctor-utils.test.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { describe, it } from 'node:test';
|
| 3 |
+
import { resolve } from 'node:path';
|
| 4 |
+
import { pathToFileURL } from 'node:url';
|
| 5 |
+
|
| 6 |
+
import {
|
| 7 |
+
assertKnownOptions,
|
| 8 |
+
assertSpaceTargetConfig,
|
| 9 |
+
buildNextActions,
|
| 10 |
+
classifyRequiredAndRecommendedNames,
|
| 11 |
+
getJsonNames,
|
| 12 |
+
isMainModule,
|
| 13 |
+
readOptionValue,
|
| 14 |
+
validateSpaceId,
|
| 15 |
+
validateSpaceUrl
|
| 16 |
+
} from './hf-space-doctor-utils.mjs';
|
| 17 |
+
|
| 18 |
+
describe('HF Space doctor utilities', () => {
|
| 19 |
+
it('parses hf CLI JSON output after hint lines', () => {
|
| 20 |
+
const names = getJsonNames(
|
| 21 |
+
[
|
| 22 |
+
'Hint: Use `hf spaces variables add user/space -e KEY=VALUE` to add variables.',
|
| 23 |
+
'[{"key":"APP_PASSWORD"},{"key":"AGENT_API_TOKEN"}]'
|
| 24 |
+
].join('\n')
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
assert.deepEqual([...names], ['APP_PASSWORD', 'AGENT_API_TOKEN']);
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
it('does not treat square brackets in hint text as JSON', () => {
|
| 31 |
+
const names = getJsonNames(
|
| 32 |
+
[
|
| 33 |
+
'Hint: Usage: hf spaces secrets list [OPTIONS] SPACE_ID',
|
| 34 |
+
'[{"key":"OPENAI_API_KEY"}]'
|
| 35 |
+
].join('\n')
|
| 36 |
+
);
|
| 37 |
+
|
| 38 |
+
assert.deepEqual([...names], ['OPENAI_API_KEY']);
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
it('keeps auth remediation broad enough for network and token failures', () => {
|
| 42 |
+
const actions = buildNextActions([
|
| 43 |
+
{
|
| 44 |
+
status: 'fail',
|
| 45 |
+
name: 'hf-auth',
|
| 46 |
+
message: 'hf CLI auth check failed.',
|
| 47 |
+
error: 'TLS failed while checking auth'
|
| 48 |
+
}
|
| 49 |
+
]);
|
| 50 |
+
|
| 51 |
+
assert.equal(actions.length, 1);
|
| 52 |
+
assert.match(actions[0], /network\/proxy/);
|
| 53 |
+
assert.match(actions[0], /hf auth login/);
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
it('classifies required and recommended remote variables separately', () => {
|
| 57 |
+
const result = classifyRequiredAndRecommendedNames(
|
| 58 |
+
new Set(['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE']),
|
| 59 |
+
['AGENT_STATE_BACKEND', 'NEXT_PUBLIC_IMAGE_STORAGE_MODE'],
|
| 60 |
+
['APP_LOG_LEVEL']
|
| 61 |
+
);
|
| 62 |
+
|
| 63 |
+
assert.deepEqual(result, {
|
| 64 |
+
missingRequired: [],
|
| 65 |
+
missingRecommended: ['APP_LOG_LEVEL']
|
| 66 |
+
});
|
| 67 |
+
});
|
| 68 |
+
|
| 69 |
+
it('detects the main module with file URL encoding', () => {
|
| 70 |
+
const argvPath = 'scripts/path with space.mjs';
|
| 71 |
+
const moduleUrl = pathToFileURL(resolve(argvPath)).href;
|
| 72 |
+
|
| 73 |
+
assert.equal(isMainModule(moduleUrl, argvPath), true);
|
| 74 |
+
assert.equal(isMainModule(moduleUrl, undefined), false);
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
it('validates Hugging Face Space target config consistently', () => {
|
| 78 |
+
assert.equal(validateSpaceId('example/demo'), undefined);
|
| 79 |
+
assert.equal(validateSpaceUrl('https://example-demo.hf.space'), undefined);
|
| 80 |
+
assert.match(validateSpaceId('bad'), /namespace\/space/);
|
| 81 |
+
assert.match(validateSpaceUrl('https://example.com'), /\.hf\.space/);
|
| 82 |
+
assert.match(validateSpaceUrl('https://user:pass@example-demo.hf.space'), /plain Space origin/);
|
| 83 |
+
assert.match(validateSpaceUrl('https://example-demo.hf.space/share/abc'), /plain Space origin/);
|
| 84 |
+
assert.match(validateSpaceUrl('https://example-demo.hf.space?token=secret'), /plain Space origin/);
|
| 85 |
+
assert.throws(
|
| 86 |
+
() =>
|
| 87 |
+
assertSpaceTargetConfig({
|
| 88 |
+
spaceId: 'example/demo',
|
| 89 |
+
spaceUrl: 'https://example.com'
|
| 90 |
+
}),
|
| 91 |
+
/\.hf\.space/
|
| 92 |
+
);
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
it('rejects unknown CLI options and blank inline option values', () => {
|
| 96 |
+
assert.doesNotThrow(() => assertKnownOptions(['--access-file', 'tmp.txt'], ['--access-file']));
|
| 97 |
+
assert.doesNotThrow(() => assertKnownOptions(['--access-file=tmp.txt'], ['--access-file']));
|
| 98 |
+
assert.throws(() => assertKnownOptions(['--unknown'], ['--access-file']), /Unknown option/);
|
| 99 |
+
assert.throws(() => readOptionValue(['--space-id='], '--space-id'), /requires a value/);
|
| 100 |
+
});
|
| 101 |
+
});
|
scripts/init-hf-space-access.mjs
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { randomBytes } from 'node:crypto';
|
| 4 |
+
import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
| 5 |
+
import { dirname } from 'node:path';
|
| 6 |
+
|
| 7 |
+
import {
|
| 8 |
+
assertKnownOptions,
|
| 9 |
+
assertSpaceTargetConfig,
|
| 10 |
+
DEFAULT_ACCESS_FILE,
|
| 11 |
+
readEnvValue,
|
| 12 |
+
readOptionValue,
|
| 13 |
+
isMainModule
|
| 14 |
+
} from './hf-space-doctor-utils.mjs';
|
| 15 |
+
|
| 16 |
+
const DEFAULT_SECRET_KEYS = ['APP_PASSWORD', 'AGENT_API_TOKEN'];
|
| 17 |
+
const APP_PASSWORD_BYTES = 24;
|
| 18 |
+
const AGENT_TOKEN_BYTES = 32;
|
| 19 |
+
|
| 20 |
+
function parseArgs(argv) {
|
| 21 |
+
assertKnownOptions(argv, ['--access-file', '--force', '--help', '-h', '--space-id', '--space-url']);
|
| 22 |
+
return {
|
| 23 |
+
accessFile: readOptionValue(argv, '--access-file') || readEnvValue('HF_SPACE_ACCESS_FILE') || DEFAULT_ACCESS_FILE,
|
| 24 |
+
force: argv.includes('--force'),
|
| 25 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 26 |
+
spaceId: readOptionValue(argv, '--space-id') || readEnvValue('HF_SPACE_ID'),
|
| 27 |
+
spaceUrl: readOptionValue(argv, '--space-url') || readEnvValue('HF_SPACE_URL')
|
| 28 |
+
};
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function printHelp() {
|
| 32 |
+
console.log(`Usage:
|
| 33 |
+
npm run init-access:hf-space -- --space-id <namespace/space> --space-url https://<user>-<space>.hf.space
|
| 34 |
+
|
| 35 |
+
Options:
|
| 36 |
+
--access-file <path> Override the access file path.
|
| 37 |
+
--force Overwrite an existing access file.
|
| 38 |
+
--help Show this help.
|
| 39 |
+
|
| 40 |
+
Environment overrides:
|
| 41 |
+
HF_SPACE_ID
|
| 42 |
+
HF_SPACE_URL
|
| 43 |
+
HF_SPACE_ACCESS_FILE`);
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
function createSecret(bytes) {
|
| 47 |
+
return randomBytes(bytes).toString('base64url');
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function buildAccessFileContent(options) {
|
| 51 |
+
return [
|
| 52 |
+
'# Hugging Face Space access for gpt-image-playground-customer',
|
| 53 |
+
'# Generated by npm run init-access:hf-space. Do not commit this file.',
|
| 54 |
+
`HF_SPACE_ID=${options.spaceId}`,
|
| 55 |
+
`HF_SPACE_URL=${options.spaceUrl}`,
|
| 56 |
+
`HF_SPACE_SECRET_KEYS=${DEFAULT_SECRET_KEYS.join(',')}`,
|
| 57 |
+
`APP_PASSWORD=${createSecret(APP_PASSWORD_BYTES)}`,
|
| 58 |
+
`AGENT_API_TOKEN=${createSecret(AGENT_TOKEN_BYTES)}`,
|
| 59 |
+
''
|
| 60 |
+
].join('\n');
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
function writeAccessFile(options) {
|
| 64 |
+
if (existsSync(options.accessFile) && !options.force) {
|
| 65 |
+
throw new Error(`Access file already exists: ${options.accessFile}. Use --force to overwrite.`);
|
| 66 |
+
}
|
| 67 |
+
mkdirSync(dirname(options.accessFile), { recursive: true });
|
| 68 |
+
writeFileSync(options.accessFile, buildAccessFileContent(options), { encoding: 'utf8', mode: 0o600 });
|
| 69 |
+
chmodSync(options.accessFile, 0o600);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function main() {
|
| 73 |
+
const options = parseArgs(process.argv.slice(2));
|
| 74 |
+
if (options.help) {
|
| 75 |
+
printHelp();
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
assertSpaceTargetConfig(options);
|
| 80 |
+
writeAccessFile(options);
|
| 81 |
+
|
| 82 |
+
console.log(
|
| 83 |
+
JSON.stringify(
|
| 84 |
+
{
|
| 85 |
+
ok: true,
|
| 86 |
+
accessFile: options.accessFile,
|
| 87 |
+
spaceId: options.spaceId,
|
| 88 |
+
spaceUrl: options.spaceUrl,
|
| 89 |
+
secretKeys: DEFAULT_SECRET_KEYS,
|
| 90 |
+
nextCommand: 'npm run sync-secret:hf-space'
|
| 91 |
+
},
|
| 92 |
+
null,
|
| 93 |
+
2
|
| 94 |
+
)
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
try {
|
| 99 |
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
| 100 |
+
main();
|
| 101 |
+
}
|
| 102 |
+
} catch (error) {
|
| 103 |
+
console.error(
|
| 104 |
+
JSON.stringify(
|
| 105 |
+
{
|
| 106 |
+
ok: false,
|
| 107 |
+
error: error instanceof Error ? error.message : String(error)
|
| 108 |
+
},
|
| 109 |
+
null,
|
| 110 |
+
2
|
| 111 |
+
)
|
| 112 |
+
);
|
| 113 |
+
process.exit(1);
|
| 114 |
+
}
|
scripts/init-hf-space-access.test.mjs
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawnSync } from 'node:child_process';
|
| 3 |
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
|
| 4 |
+
import { tmpdir } from 'node:os';
|
| 5 |
+
import { join } from 'node:path';
|
| 6 |
+
import { describe, it } from 'node:test';
|
| 7 |
+
import { fileURLToPath } from 'node:url';
|
| 8 |
+
|
| 9 |
+
const SCRIPT_PATH = fileURLToPath(new URL('./init-hf-space-access.mjs', import.meta.url));
|
| 10 |
+
const SCRIPT_URL = new URL('./init-hf-space-access.mjs', import.meta.url).href;
|
| 11 |
+
|
| 12 |
+
function runInit(args) {
|
| 13 |
+
return spawnSync(process.execPath, [SCRIPT_PATH, ...args], {
|
| 14 |
+
encoding: 'utf8',
|
| 15 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 16 |
+
});
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function makeTempDir() {
|
| 20 |
+
return mkdtempSync(join(tmpdir(), 'gpt-image-playground-access-'));
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
describe('HF Space access initializer', () => {
|
| 24 |
+
it('can be imported without executing the CLI', async () => {
|
| 25 |
+
await assert.doesNotReject(import(`${SCRIPT_URL}?import-smoke=${Date.now()}`));
|
| 26 |
+
});
|
| 27 |
+
|
| 28 |
+
it('rejects unknown options before writing an access file', () => {
|
| 29 |
+
const dir = makeTempDir();
|
| 30 |
+
const accessFile = join(dir, 'access.txt');
|
| 31 |
+
const result = runInit([
|
| 32 |
+
'--space-id',
|
| 33 |
+
'example/demo',
|
| 34 |
+
'--space-url',
|
| 35 |
+
'https://example-demo.hf.space',
|
| 36 |
+
'--unknown',
|
| 37 |
+
'--access-file',
|
| 38 |
+
accessFile
|
| 39 |
+
]);
|
| 40 |
+
|
| 41 |
+
assert.notEqual(result.status, 0);
|
| 42 |
+
assert.match(result.stderr, /Unknown option/);
|
| 43 |
+
assert.equal(existsSync(accessFile), false);
|
| 44 |
+
rmSync(dir, { force: true, recursive: true });
|
| 45 |
+
});
|
| 46 |
+
|
| 47 |
+
it('rejects invalid Space ids before writing an access file', () => {
|
| 48 |
+
const dir = makeTempDir();
|
| 49 |
+
const accessFile = join(dir, 'access.txt');
|
| 50 |
+
const result = runInit([
|
| 51 |
+
'--space-id',
|
| 52 |
+
'bad',
|
| 53 |
+
'--space-url',
|
| 54 |
+
'https://example-demo.hf.space',
|
| 55 |
+
'--access-file',
|
| 56 |
+
accessFile
|
| 57 |
+
]);
|
| 58 |
+
|
| 59 |
+
assert.notEqual(result.status, 0);
|
| 60 |
+
assert.match(result.stderr, /namespace\/space/);
|
| 61 |
+
assert.equal(existsSync(accessFile), false);
|
| 62 |
+
rmSync(dir, { force: true, recursive: true });
|
| 63 |
+
});
|
| 64 |
+
|
| 65 |
+
it('rejects non-Hugging Face Space URLs before writing an access file', () => {
|
| 66 |
+
const dir = makeTempDir();
|
| 67 |
+
const accessFile = join(dir, 'access.txt');
|
| 68 |
+
const result = runInit([
|
| 69 |
+
'--space-id',
|
| 70 |
+
'example/demo',
|
| 71 |
+
'--space-url',
|
| 72 |
+
'https://example.com',
|
| 73 |
+
'--access-file',
|
| 74 |
+
accessFile
|
| 75 |
+
]);
|
| 76 |
+
|
| 77 |
+
assert.notEqual(result.status, 0);
|
| 78 |
+
assert.match(result.stderr, /\.hf\.space/);
|
| 79 |
+
assert.equal(existsSync(accessFile), false);
|
| 80 |
+
rmSync(dir, { force: true, recursive: true });
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
it('writes an access file without leaking generated secrets to stdout', () => {
|
| 84 |
+
const dir = makeTempDir();
|
| 85 |
+
const accessFile = join(dir, 'access.txt');
|
| 86 |
+
const result = runInit([
|
| 87 |
+
'--space-id',
|
| 88 |
+
'example/demo',
|
| 89 |
+
'--space-url',
|
| 90 |
+
'https://example-demo.hf.space',
|
| 91 |
+
'--access-file',
|
| 92 |
+
accessFile
|
| 93 |
+
]);
|
| 94 |
+
|
| 95 |
+
assert.equal(result.status, 0, result.stderr);
|
| 96 |
+
assert.doesNotMatch(result.stdout, /APP_PASSWORD=|AGENT_API_TOKEN=/);
|
| 97 |
+
|
| 98 |
+
const content = readFileSync(accessFile, 'utf8');
|
| 99 |
+
assert.match(content, /^HF_SPACE_ID=example\/demo$/m);
|
| 100 |
+
assert.match(content, /^HF_SPACE_URL=https:\/\/example-demo\.hf\.space$/m);
|
| 101 |
+
assert.match(content, /^HF_SPACE_SECRET_KEYS=APP_PASSWORD,AGENT_API_TOKEN$/m);
|
| 102 |
+
if (process.platform !== 'win32') {
|
| 103 |
+
assert.equal(statSync(accessFile).mode & 0o777, 0o600);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
rmSync(dir, { force: true, recursive: true });
|
| 107 |
+
});
|
| 108 |
+
});
|
scripts/keepalive-hf-space.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
|
|
|
|
|
|
|
| 3 |
const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
| 4 |
const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
|
| 5 |
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
@@ -8,7 +10,10 @@ const MIN_TIMEOUT_MS = 1_000;
|
|
| 8 |
function readPositiveIntegerEnv(name, fallback) {
|
| 9 |
const rawValue = process.env[name]?.trim();
|
| 10 |
if (!rawValue) return fallback;
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) {
|
| 13 |
throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
|
| 14 |
}
|
|
@@ -16,6 +21,10 @@ function readPositiveIntegerEnv(name, fallback) {
|
|
| 16 |
}
|
| 17 |
|
| 18 |
function normalizeUrl(rawUrl, path) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
const baseUrl = new URL(rawUrl);
|
| 20 |
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
| 21 |
return new URL(normalizedPath, baseUrl).toString();
|
|
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
|
| 3 |
+
import { validateSpaceUrl } from './hf-space-doctor-utils.mjs';
|
| 4 |
+
|
| 5 |
const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
| 6 |
const DEFAULT_KEEPALIVE_PATH = '/api/auth-status';
|
| 7 |
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
|
| 10 |
function readPositiveIntegerEnv(name, fallback) {
|
| 11 |
const rawValue = process.env[name]?.trim();
|
| 12 |
if (!rawValue) return fallback;
|
| 13 |
+
if (!/^\d+$/.test(rawValue)) {
|
| 14 |
+
throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
|
| 15 |
+
}
|
| 16 |
+
const value = Number(rawValue);
|
| 17 |
if (!Number.isSafeInteger(value) || value < MIN_TIMEOUT_MS) {
|
| 18 |
throw new Error(`${name} must be an integer greater than or equal to ${MIN_TIMEOUT_MS}`);
|
| 19 |
}
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
function normalizeUrl(rawUrl, path) {
|
| 24 |
+
const urlError = validateSpaceUrl(rawUrl);
|
| 25 |
+
if (urlError) {
|
| 26 |
+
throw new Error(urlError.replace('HF Space URL', 'HF_SPACE_KEEPALIVE_URL'));
|
| 27 |
+
}
|
| 28 |
const baseUrl = new URL(rawUrl);
|
| 29 |
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
| 30 |
return new URL(normalizedPath, baseUrl).toString();
|
scripts/keepalive-hf-space.test.mjs
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawnSync } from 'node:child_process';
|
| 3 |
+
import { fileURLToPath } from 'node:url';
|
| 4 |
+
import { join } from 'node:path';
|
| 5 |
+
import { describe, it } from 'node:test';
|
| 6 |
+
|
| 7 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 8 |
+
const scriptPath = join(repoRoot, 'scripts/keepalive-hf-space.mjs');
|
| 9 |
+
|
| 10 |
+
describe('HF Space keepalive script validation', () => {
|
| 11 |
+
it('rejects non-integer timeout env values before network access', () => {
|
| 12 |
+
const result = runKeepalive({ HF_SPACE_KEEPALIVE_TIMEOUT_MS: '1000abc' });
|
| 13 |
+
|
| 14 |
+
assert.equal(result.status, 1);
|
| 15 |
+
assert.match(result.stderr, /HF_SPACE_KEEPALIVE_TIMEOUT_MS/);
|
| 16 |
+
assert.match(result.stderr, /integer/);
|
| 17 |
+
assert.equal(result.stdout.trim(), '');
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
it('rejects keepalive URLs with embedded credentials before network access', () => {
|
| 21 |
+
const result = runKeepalive({ HF_SPACE_KEEPALIVE_URL: 'https://user:secret@example-demo.hf.space' });
|
| 22 |
+
|
| 23 |
+
assert.equal(result.status, 1);
|
| 24 |
+
assert.match(result.stderr, /HF_SPACE_KEEPALIVE_URL/);
|
| 25 |
+
assert.match(result.stderr, /plain Space origin/);
|
| 26 |
+
assert.doesNotMatch(result.stderr, /secret/);
|
| 27 |
+
assert.equal(result.stdout.trim(), '');
|
| 28 |
+
});
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
function runKeepalive(env) {
|
| 32 |
+
return spawnSync(process.execPath, [scriptPath], {
|
| 33 |
+
cwd: repoRoot,
|
| 34 |
+
encoding: 'utf8',
|
| 35 |
+
env: { ...process.env, ...env }
|
| 36 |
+
});
|
| 37 |
+
}
|
scripts/smoke-hf-space-memory.mjs
CHANGED
|
@@ -65,6 +65,14 @@ function assertEqual(actual, expected, label) {
|
|
| 65 |
}
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
cleanup();
|
| 69 |
|
| 70 |
try {
|
|
@@ -106,6 +114,7 @@ try {
|
|
| 106 |
|
| 107 |
const capabilities = await fetchJson('/api/agent/capabilities');
|
| 108 |
assertEqual(capabilities.auth?.required, true, 'Agent auth required flag');
|
|
|
|
| 109 |
assertEqual(capabilities.defaults?.state_backend, 'memory', 'Agent state backend');
|
| 110 |
assertEqual(capabilities.storage?.image_storage_mode, 'indexeddb', 'Image storage mode');
|
| 111 |
assertEqual(capabilities.storage?.postgres_configured, false, 'PostgreSQL configured flag');
|
|
|
|
| 65 |
}
|
| 66 |
}
|
| 67 |
|
| 68 |
+
function assertJsonEqual(actual, expected, label) {
|
| 69 |
+
const actualJson = JSON.stringify(actual);
|
| 70 |
+
const expectedJson = JSON.stringify(expected);
|
| 71 |
+
if (actualJson !== expectedJson) {
|
| 72 |
+
throw new Error(`${label}: expected ${expectedJson}, got ${actualJson}`);
|
| 73 |
+
}
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
cleanup();
|
| 77 |
|
| 78 |
try {
|
|
|
|
| 114 |
|
| 115 |
const capabilities = await fetchJson('/api/agent/capabilities');
|
| 116 |
assertEqual(capabilities.auth?.required, true, 'Agent auth required flag');
|
| 117 |
+
assertJsonEqual(capabilities.auth?.schemes, ['bearer'], 'Agent auth schemes');
|
| 118 |
assertEqual(capabilities.defaults?.state_backend, 'memory', 'Agent state backend');
|
| 119 |
assertEqual(capabilities.storage?.image_storage_mode, 'indexeddb', 'Image storage mode');
|
| 120 |
assertEqual(capabilities.storage?.postgres_configured, false, 'PostgreSQL configured flag');
|
scripts/sync-hf-space-secret.mjs
CHANGED
|
@@ -2,34 +2,68 @@
|
|
| 2 |
|
| 3 |
import { execFileSync, spawnSync } from 'node:child_process';
|
| 4 |
import { createHash } from 'node:crypto';
|
| 5 |
-
import { readFileSync } from 'node:fs';
|
| 6 |
-
import { join } from 'node:path';
|
| 7 |
import { setTimeout as delay } from 'node:timers/promises';
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
const DEFAULT_SPACE_ID = 'misonL/gpt-image-playground-customer';
|
| 10 |
const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
| 11 |
-
const DEFAULT_ACCESS_FILE = join(process.env.HOME || '', '.cache/gpt-image-playground-customer/hf-space-access.txt');
|
| 12 |
const DEFAULT_SECRET_KEYS = ['APP_PASSWORD'];
|
|
|
|
| 13 |
const STATUS_POLL_ATTEMPTS = 20;
|
| 14 |
const STATUS_POLL_INTERVAL_MS = 5_000;
|
| 15 |
const VERIFY_ATTEMPTS = 6;
|
| 16 |
const VERIFY_INTERVAL_MS = 3_000;
|
| 17 |
|
|
|
|
|
|
|
| 18 |
function parseArgs(argv) {
|
|
|
|
| 19 |
return {
|
|
|
|
| 20 |
restart: !argv.includes('--no-restart'),
|
|
|
|
| 21 |
verify: !argv.includes('--skip-verify')
|
| 22 |
};
|
| 23 |
}
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
function readRequiredEnv(name, fallback) {
|
| 26 |
const value = process.env[name]?.trim() || fallback;
|
| 27 |
if (!value) throw new Error(`${name} is required`);
|
| 28 |
return value;
|
| 29 |
}
|
| 30 |
|
| 31 |
-
function
|
| 32 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if (!rawValue) return DEFAULT_SECRET_KEYS;
|
| 34 |
const keys = rawValue
|
| 35 |
.split(',')
|
|
@@ -39,18 +73,18 @@ function readSecretKeys() {
|
|
| 39 |
return keys;
|
| 40 |
}
|
| 41 |
|
| 42 |
-
function
|
| 43 |
-
const
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
if (!rawLine || rawLine.startsWith('#')) continue;
|
| 47 |
-
const separatorIndex = rawLine.indexOf('=');
|
| 48 |
-
if (separatorIndex <= 0) continue;
|
| 49 |
-
const key = rawLine.slice(0, separatorIndex).trim();
|
| 50 |
-
const value = rawLine.slice(separatorIndex + 1);
|
| 51 |
-
if (key) secrets.set(key, value);
|
| 52 |
}
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
}
|
| 55 |
|
| 56 |
function redactSensitiveText(text, secretValues = []) {
|
|
@@ -62,18 +96,40 @@ function redactSensitiveText(text, secretValues = []) {
|
|
| 62 |
}
|
| 63 |
|
| 64 |
function runHf(args, options = {}) {
|
|
|
|
| 65 |
const result = spawnSync('hf', args, {
|
| 66 |
encoding: 'utf8',
|
| 67 |
stdio: ['ignore', 'pipe', 'pipe']
|
| 68 |
});
|
|
|
|
|
|
|
|
|
|
| 69 |
if (result.status !== 0) {
|
| 70 |
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 71 |
-
const safeOutput = redactSensitiveText(output || `
|
| 72 |
throw new Error(safeOutput);
|
| 73 |
}
|
| 74 |
return result.stdout || '';
|
| 75 |
}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
async function syncSecret({ spaceId, key, value }) {
|
| 78 |
let lastError;
|
| 79 |
for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) {
|
|
@@ -83,6 +139,7 @@ async function syncSecret({ spaceId, key, value }) {
|
|
| 83 |
});
|
| 84 |
return;
|
| 85 |
} catch (error) {
|
|
|
|
| 86 |
lastError = error;
|
| 87 |
await delay(VERIFY_INTERVAL_MS);
|
| 88 |
}
|
|
@@ -144,9 +201,9 @@ async function verifyAppPassword({ spaceUrl, appPassword }) {
|
|
| 144 |
headers: { 'Content-Type': 'application/json' },
|
| 145 |
body: JSON.stringify({ passwordHash })
|
| 146 |
});
|
| 147 |
-
const body = await
|
| 148 |
if (!response.ok || body?.authenticated !== true) {
|
| 149 |
-
throw new Error(`APP_PASSWORD verification failed with HTTP ${response.status}`);
|
| 150 |
}
|
| 151 |
return { status: response.status, authenticated: body.authenticated };
|
| 152 |
} catch (error) {
|
|
@@ -157,18 +214,39 @@ async function verifyAppPassword({ spaceUrl, appPassword }) {
|
|
| 157 |
throw lastError;
|
| 158 |
}
|
| 159 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
async function main() {
|
| 161 |
const options = parseArgs(process.argv.slice(2));
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
|
|
|
| 164 |
const accessFile = readRequiredEnv('HF_SPACE_ACCESS_FILE', DEFAULT_ACCESS_FILE);
|
| 165 |
-
const
|
| 166 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
const syncedKeys = [];
|
| 168 |
|
|
|
|
|
|
|
|
|
|
| 169 |
for (const key of secretKeys) {
|
| 170 |
-
const value =
|
| 171 |
-
if (!value?.trim()) throw new Error(`${key} is missing or blank in access file`);
|
| 172 |
await syncSecret({ spaceId, key, value });
|
| 173 |
syncedKeys.push(key);
|
| 174 |
}
|
|
@@ -186,7 +264,7 @@ async function main() {
|
|
| 186 |
if (options.verify && syncedKeys.includes('APP_PASSWORD')) {
|
| 187 |
verification = await verifyAppPassword({
|
| 188 |
spaceUrl,
|
| 189 |
-
appPassword:
|
| 190 |
});
|
| 191 |
}
|
| 192 |
|
|
@@ -206,16 +284,18 @@ async function main() {
|
|
| 206 |
);
|
| 207 |
}
|
| 208 |
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import { execFileSync, spawnSync } from 'node:child_process';
|
| 4 |
import { createHash } from 'node:crypto';
|
|
|
|
|
|
|
| 5 |
import { setTimeout as delay } from 'node:timers/promises';
|
| 6 |
|
| 7 |
+
import {
|
| 8 |
+
assertKnownOptions,
|
| 9 |
+
assertSpaceTargetConfig,
|
| 10 |
+
DEFAULT_ACCESS_FILE,
|
| 11 |
+
isMainModule,
|
| 12 |
+
parseAccessFile
|
| 13 |
+
} from './hf-space-doctor-utils.mjs';
|
| 14 |
+
|
| 15 |
const DEFAULT_SPACE_ID = 'misonL/gpt-image-playground-customer';
|
| 16 |
const DEFAULT_SPACE_URL = 'https://misonl-gpt-image-playground-customer.hf.space';
|
|
|
|
| 17 |
const DEFAULT_SECRET_KEYS = ['APP_PASSWORD'];
|
| 18 |
+
const FORBIDDEN_ACCESS_KEYS = ['HF_TOKEN', 'HUGGINGFACE_TOKEN', 'HF_PASSWORD', 'HUGGINGFACE_PASSWORD'];
|
| 19 |
const STATUS_POLL_ATTEMPTS = 20;
|
| 20 |
const STATUS_POLL_INTERVAL_MS = 5_000;
|
| 21 |
const VERIFY_ATTEMPTS = 6;
|
| 22 |
const VERIFY_INTERVAL_MS = 3_000;
|
| 23 |
|
| 24 |
+
class NonRetryableHfError extends Error {}
|
| 25 |
+
|
| 26 |
function parseArgs(argv) {
|
| 27 |
+
assertKnownOptions(argv, ['--help', '-h', '--no-restart', '--skip-verify', '--use-default-target']);
|
| 28 |
return {
|
| 29 |
+
help: argv.includes('--help') || argv.includes('-h'),
|
| 30 |
restart: !argv.includes('--no-restart'),
|
| 31 |
+
useDefaultTarget: argv.includes('--use-default-target'),
|
| 32 |
verify: !argv.includes('--skip-verify')
|
| 33 |
};
|
| 34 |
}
|
| 35 |
|
| 36 |
+
function printHelp() {
|
| 37 |
+
console.log(`Usage:
|
| 38 |
+
npm run sync-secret:hf-space
|
| 39 |
+
|
| 40 |
+
Options:
|
| 41 |
+
--no-restart Sync secrets without restarting the Space.
|
| 42 |
+
--skip-verify Skip APP_PASSWORD access-code verification.
|
| 43 |
+
--use-default-target Allow the built-in default Space target.
|
| 44 |
+
--help Show this help.
|
| 45 |
+
|
| 46 |
+
Environment overrides:
|
| 47 |
+
HF_SPACE_ACCESS_FILE
|
| 48 |
+
HF_SPACE_ID
|
| 49 |
+
HF_SPACE_URL
|
| 50 |
+
HF_SPACE_SECRET_KEYS`);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
function readRequiredEnv(name, fallback) {
|
| 54 |
const value = process.env[name]?.trim() || fallback;
|
| 55 |
if (!value) throw new Error(`${name} is required`);
|
| 56 |
return value;
|
| 57 |
}
|
| 58 |
|
| 59 |
+
function readConfigValue(name, secrets, fallback) {
|
| 60 |
+
const value = process.env[name]?.trim() || secrets.get(name)?.trim() || fallback;
|
| 61 |
+
if (!value) throw new Error(`${name} is required`);
|
| 62 |
+
return value;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function readSecretKeys(secrets) {
|
| 66 |
+
const rawValue = process.env.HF_SPACE_SECRET_KEYS?.trim() || secrets.get('HF_SPACE_SECRET_KEYS')?.trim();
|
| 67 |
if (!rawValue) return DEFAULT_SECRET_KEYS;
|
| 68 |
const keys = rawValue
|
| 69 |
.split(',')
|
|
|
|
| 73 |
return keys;
|
| 74 |
}
|
| 75 |
|
| 76 |
+
function validateSecretValues(secretKeys, secrets) {
|
| 77 |
+
const forbidden = FORBIDDEN_ACCESS_KEYS.filter((key) => secrets.has(key));
|
| 78 |
+
if (forbidden.length) {
|
| 79 |
+
throw new Error(`Access file must not contain Hugging Face credentials: ${forbidden.join(', ')}.`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
}
|
| 81 |
+
const secretValues = new Map();
|
| 82 |
+
for (const key of secretKeys) {
|
| 83 |
+
const value = secrets.get(key);
|
| 84 |
+
if (!value?.trim()) throw new Error(`${key} is missing or blank in access file`);
|
| 85 |
+
secretValues.set(key, value);
|
| 86 |
+
}
|
| 87 |
+
return secretValues;
|
| 88 |
}
|
| 89 |
|
| 90 |
function redactSensitiveText(text, secretValues = []) {
|
|
|
|
| 96 |
}
|
| 97 |
|
| 98 |
function runHf(args, options = {}) {
|
| 99 |
+
const commandLabel = redactSensitiveText(`hf ${args.join(' ')}`, options.secretValues);
|
| 100 |
const result = spawnSync('hf', args, {
|
| 101 |
encoding: 'utf8',
|
| 102 |
stdio: ['ignore', 'pipe', 'pipe']
|
| 103 |
});
|
| 104 |
+
if (result.error) {
|
| 105 |
+
throw new NonRetryableHfError(`${commandLabel} failed: ${result.error.message}`);
|
| 106 |
+
}
|
| 107 |
if (result.status !== 0) {
|
| 108 |
const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
| 109 |
+
const safeOutput = redactSensitiveText(output || `${commandLabel} failed`, options.secretValues);
|
| 110 |
throw new Error(safeOutput);
|
| 111 |
}
|
| 112 |
return result.stdout || '';
|
| 113 |
}
|
| 114 |
|
| 115 |
+
function assertHfAuthenticated() {
|
| 116 |
+
try {
|
| 117 |
+
runHf(['auth', 'whoami']);
|
| 118 |
+
} catch (error) {
|
| 119 |
+
const cause = error instanceof Error ? error.message : String(error);
|
| 120 |
+
throw new Error(`Hugging Face CLI is not authenticated. Run "hf auth login" with an access token that can manage the target Space. Cause: ${cause}`);
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function assertSpaceReadable(spaceId) {
|
| 125 |
+
try {
|
| 126 |
+
runHf(['spaces', 'info', spaceId, '--format', 'json']);
|
| 127 |
+
} catch (error) {
|
| 128 |
+
const cause = error instanceof Error ? error.message : String(error);
|
| 129 |
+
throw new Error(`Cannot read Hugging Face Space "${spaceId}". Check HF_SPACE_ID and the logged-in token permissions. Cause: ${cause}`);
|
| 130 |
+
}
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
async function syncSecret({ spaceId, key, value }) {
|
| 134 |
let lastError;
|
| 135 |
for (let attempt = 0; attempt < VERIFY_ATTEMPTS; attempt += 1) {
|
|
|
|
| 139 |
});
|
| 140 |
return;
|
| 141 |
} catch (error) {
|
| 142 |
+
if (error instanceof NonRetryableHfError) throw error;
|
| 143 |
lastError = error;
|
| 144 |
await delay(VERIFY_INTERVAL_MS);
|
| 145 |
}
|
|
|
|
| 201 |
headers: { 'Content-Type': 'application/json' },
|
| 202 |
body: JSON.stringify({ passwordHash })
|
| 203 |
});
|
| 204 |
+
const body = await readJsonResponseBody(response);
|
| 205 |
if (!response.ok || body?.authenticated !== true) {
|
| 206 |
+
throw new Error(`APP_PASSWORD access-code verification failed with HTTP ${response.status}`);
|
| 207 |
}
|
| 208 |
return { status: response.status, authenticated: body.authenticated };
|
| 209 |
} catch (error) {
|
|
|
|
| 214 |
throw lastError;
|
| 215 |
}
|
| 216 |
|
| 217 |
+
export async function readJsonResponseBody(response) {
|
| 218 |
+
const text = await response.text();
|
| 219 |
+
if (!text) return {};
|
| 220 |
+
try {
|
| 221 |
+
return JSON.parse(text);
|
| 222 |
+
} catch {
|
| 223 |
+
return { raw: text.slice(0, 200) };
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
async function main() {
|
| 228 |
const options = parseArgs(process.argv.slice(2));
|
| 229 |
+
if (options.help) {
|
| 230 |
+
printHelp();
|
| 231 |
+
return;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
const accessFile = readRequiredEnv('HF_SPACE_ACCESS_FILE', DEFAULT_ACCESS_FILE);
|
| 235 |
+
const secrets = parseAccessFile(accessFile);
|
| 236 |
+
const fallbackSpaceId = options.useDefaultTarget ? DEFAULT_SPACE_ID : undefined;
|
| 237 |
+
const fallbackSpaceUrl = options.useDefaultTarget ? DEFAULT_SPACE_URL : undefined;
|
| 238 |
+
const spaceId = readConfigValue('HF_SPACE_ID', secrets, fallbackSpaceId);
|
| 239 |
+
const spaceUrl = readConfigValue('HF_SPACE_URL', secrets, fallbackSpaceUrl);
|
| 240 |
+
assertSpaceTargetConfig({ spaceId, spaceUrl });
|
| 241 |
+
const secretKeys = readSecretKeys(secrets);
|
| 242 |
+
const secretValues = validateSecretValues(secretKeys, secrets);
|
| 243 |
const syncedKeys = [];
|
| 244 |
|
| 245 |
+
assertHfAuthenticated();
|
| 246 |
+
assertSpaceReadable(spaceId);
|
| 247 |
+
|
| 248 |
for (const key of secretKeys) {
|
| 249 |
+
const value = secretValues.get(key);
|
|
|
|
| 250 |
await syncSecret({ spaceId, key, value });
|
| 251 |
syncedKeys.push(key);
|
| 252 |
}
|
|
|
|
| 264 |
if (options.verify && syncedKeys.includes('APP_PASSWORD')) {
|
| 265 |
verification = await verifyAppPassword({
|
| 266 |
spaceUrl,
|
| 267 |
+
appPassword: secretValues.get('APP_PASSWORD')
|
| 268 |
});
|
| 269 |
}
|
| 270 |
|
|
|
|
| 284 |
);
|
| 285 |
}
|
| 286 |
|
| 287 |
+
if (isMainModule(import.meta.url, process.argv[1])) {
|
| 288 |
+
main().catch((error) => {
|
| 289 |
+
console.error(
|
| 290 |
+
JSON.stringify(
|
| 291 |
+
{
|
| 292 |
+
ok: false,
|
| 293 |
+
error: error instanceof Error ? error.message : String(error)
|
| 294 |
+
},
|
| 295 |
+
null,
|
| 296 |
+
2
|
| 297 |
+
)
|
| 298 |
+
);
|
| 299 |
+
process.exit(1);
|
| 300 |
+
});
|
| 301 |
+
}
|
scripts/sync-hf-space-secret.test.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawnSync } from 'node:child_process';
|
| 3 |
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
| 4 |
+
import { tmpdir } from 'node:os';
|
| 5 |
+
import { join } from 'node:path';
|
| 6 |
+
import { describe, it } from 'node:test';
|
| 7 |
+
import { fileURLToPath } from 'node:url';
|
| 8 |
+
|
| 9 |
+
const SCRIPT_PATH = fileURLToPath(new URL('./sync-hf-space-secret.mjs', import.meta.url));
|
| 10 |
+
const SCRIPT_URL = new URL('./sync-hf-space-secret.mjs', import.meta.url).href;
|
| 11 |
+
|
| 12 |
+
function makeTempDir() {
|
| 13 |
+
return mkdtempSync(join(tmpdir(), 'gpt-image-playground-sync-'));
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function runSync(accessFile, env = {}) {
|
| 17 |
+
return spawnSync(process.execPath, [SCRIPT_PATH, '--no-restart', '--skip-verify'], {
|
| 18 |
+
encoding: 'utf8',
|
| 19 |
+
env: {
|
| 20 |
+
...process.env,
|
| 21 |
+
HF_SPACE_ACCESS_FILE: accessFile,
|
| 22 |
+
...env
|
| 23 |
+
},
|
| 24 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 25 |
+
});
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
describe('HF Space secret sync preflight', () => {
|
| 29 |
+
it('can be imported without executing the CLI', async () => {
|
| 30 |
+
await assert.doesNotReject(import(`${SCRIPT_URL}?import-smoke=${Date.now()}`));
|
| 31 |
+
});
|
| 32 |
+
|
| 33 |
+
it('parses verification responses without reading non-JSON bodies twice', async () => {
|
| 34 |
+
const module = await import(`${SCRIPT_URL}?json-body=${Date.now()}`);
|
| 35 |
+
const body = await module.readJsonResponseBody(new Response('not json', { status: 502 }));
|
| 36 |
+
|
| 37 |
+
assert.deepEqual(body, { raw: 'not json' });
|
| 38 |
+
});
|
| 39 |
+
|
| 40 |
+
it('prints help without requiring an access file or remote auth', () => {
|
| 41 |
+
const result = spawnSync(process.execPath, [SCRIPT_PATH, '--help'], {
|
| 42 |
+
encoding: 'utf8',
|
| 43 |
+
env: {
|
| 44 |
+
...process.env,
|
| 45 |
+
HF_SPACE_ACCESS_FILE: join(tmpdir(), 'missing-access-file.txt')
|
| 46 |
+
},
|
| 47 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 48 |
+
});
|
| 49 |
+
|
| 50 |
+
assert.equal(result.status, 0, result.stderr);
|
| 51 |
+
assert.match(result.stdout, /Usage:/);
|
| 52 |
+
assert.match(result.stdout, /--use-default-target/);
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
it('rejects unknown options before remote operations', () => {
|
| 56 |
+
const result = spawnSync(process.execPath, [SCRIPT_PATH, '--unknown'], {
|
| 57 |
+
encoding: 'utf8',
|
| 58 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 59 |
+
});
|
| 60 |
+
|
| 61 |
+
assert.notEqual(result.status, 0);
|
| 62 |
+
assert.match(result.stderr, /Unknown option/);
|
| 63 |
+
assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/);
|
| 64 |
+
});
|
| 65 |
+
|
| 66 |
+
it('rejects non-Hugging Face Space URLs before remote operations', () => {
|
| 67 |
+
const dir = makeTempDir();
|
| 68 |
+
const accessFile = join(dir, 'access.txt');
|
| 69 |
+
writeFileSync(
|
| 70 |
+
accessFile,
|
| 71 |
+
[
|
| 72 |
+
'HF_SPACE_ID=example/demo',
|
| 73 |
+
'HF_SPACE_URL=https://example.com',
|
| 74 |
+
'HF_SPACE_SECRET_KEYS=APP_PASSWORD',
|
| 75 |
+
'APP_PASSWORD=abcdefghijklmnopqrstuvwxyz',
|
| 76 |
+
''
|
| 77 |
+
].join('\n'),
|
| 78 |
+
{ encoding: 'utf8', mode: 0o600 }
|
| 79 |
+
);
|
| 80 |
+
|
| 81 |
+
const result = runSync(accessFile);
|
| 82 |
+
|
| 83 |
+
assert.notEqual(result.status, 0);
|
| 84 |
+
assert.match(result.stderr, /\.hf\.space/);
|
| 85 |
+
assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/);
|
| 86 |
+
|
| 87 |
+
rmSync(dir, { force: true, recursive: true });
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it('rejects Hugging Face account credentials in the access file before remote operations', () => {
|
| 91 |
+
const dir = makeTempDir();
|
| 92 |
+
const accessFile = join(dir, 'access.txt');
|
| 93 |
+
writeFileSync(
|
| 94 |
+
accessFile,
|
| 95 |
+
[
|
| 96 |
+
'HF_SPACE_ID=example/demo',
|
| 97 |
+
'HF_SPACE_URL=https://example-demo.hf.space',
|
| 98 |
+
'HF_SPACE_SECRET_KEYS=APP_PASSWORD',
|
| 99 |
+
'APP_PASSWORD=abcdefghijklmnopqrstuvwxyz',
|
| 100 |
+
'HF_TOKEN=hf_account_token_should_not_be_synced',
|
| 101 |
+
''
|
| 102 |
+
].join('\n'),
|
| 103 |
+
{ encoding: 'utf8', mode: 0o600 }
|
| 104 |
+
);
|
| 105 |
+
|
| 106 |
+
const result = runSync(accessFile);
|
| 107 |
+
|
| 108 |
+
assert.notEqual(result.status, 0);
|
| 109 |
+
assert.match(result.stderr, /must not contain Hugging Face credentials/);
|
| 110 |
+
assert.doesNotMatch(result.stderr, /hf auth login|Cannot read Hugging Face Space/);
|
| 111 |
+
|
| 112 |
+
rmSync(dir, { force: true, recursive: true });
|
| 113 |
+
});
|
| 114 |
+
|
| 115 |
+
it('redacts secret values if hf fails while syncing a secret', () => {
|
| 116 |
+
const dir = makeTempDir();
|
| 117 |
+
const accessFile = join(dir, 'access.txt');
|
| 118 |
+
const hfPath = join(dir, 'hf');
|
| 119 |
+
const secretValue = 'super-secret-access-code';
|
| 120 |
+
writeFileSync(
|
| 121 |
+
accessFile,
|
| 122 |
+
[
|
| 123 |
+
'HF_SPACE_ID=example/demo',
|
| 124 |
+
'HF_SPACE_URL=https://example-demo.hf.space',
|
| 125 |
+
'HF_SPACE_SECRET_KEYS=APP_PASSWORD',
|
| 126 |
+
`APP_PASSWORD=${secretValue}`,
|
| 127 |
+
''
|
| 128 |
+
].join('\n'),
|
| 129 |
+
{ encoding: 'utf8', mode: 0o600 }
|
| 130 |
+
);
|
| 131 |
+
writeFileSync(
|
| 132 |
+
hfPath,
|
| 133 |
+
[
|
| 134 |
+
'#!/bin/sh',
|
| 135 |
+
'if [ "$1 $2" = "auth whoami" ]; then exit 0; fi',
|
| 136 |
+
'if [ "$1 $2" = "spaces info" ]; then /bin/chmod 000 "$0"; echo "{}"; exit 0; fi',
|
| 137 |
+
'exit 1',
|
| 138 |
+
''
|
| 139 |
+
].join('\n'),
|
| 140 |
+
{ encoding: 'utf8', mode: 0o755 }
|
| 141 |
+
);
|
| 142 |
+
|
| 143 |
+
const result = runSync(accessFile, { PATH: dir });
|
| 144 |
+
|
| 145 |
+
assert.notEqual(result.status, 0);
|
| 146 |
+
assert.doesNotMatch(result.stderr, new RegExp(secretValue));
|
| 147 |
+
assert.match(result.stderr, /APP_PASSWORD=\[redacted\]/);
|
| 148 |
+
|
| 149 |
+
rmSync(dir, { force: true, recursive: true });
|
| 150 |
+
});
|
| 151 |
+
});
|
skills/gpt-image-playground-agent/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
---
|
| 2 |
name: gpt-image-playground-agent
|
| 3 |
-
description: 调用已部署的 GPT Image Playground
|
| 4 |
---
|
| 5 |
|
| 6 |
# GPT Image Playground Agent
|
|
@@ -11,13 +11,15 @@ description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、
|
|
| 11 |
|
| 12 |
1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
|
| 13 |
2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。
|
| 14 |
-
3. 读取 capabilities 中的认证方式、模型、限制、状态后端和端点路径;不要硬编码假设部署方式。
|
| 15 |
4. 为每个业务操作生成稳定的 `Idempotency-Key`。同一操作重试时复用原 key;不同操作不要复用。
|
| 16 |
-
5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。
|
| 17 |
-
6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。
|
| 18 |
7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
|
| 19 |
-
8.
|
| 20 |
-
9.
|
|
|
|
|
|
|
| 21 |
|
| 22 |
## 鉴权
|
| 23 |
|
|
@@ -27,30 +29,85 @@ description: 调用已部署的 GPT Image Playground Agent API,面向 Codex、
|
|
| 27 |
Authorization: Bearer <token>
|
| 28 |
```
|
| 29 |
|
| 30 |
-
|
| 31 |
|
| 32 |
## 调用约束
|
| 33 |
|
| 34 |
-
- 不要把 API Key、token 或
|
| 35 |
- 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
|
| 36 |
- 不要在模型上下文中展开大体积 base64,除非用户明确要求。
|
| 37 |
- 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
|
| 38 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
## 可用脚本
|
| 41 |
|
| 42 |
-
- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
|
| 43 |
-
- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
脚本读取以下环境变量:
|
| 46 |
|
| 47 |
- `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
|
| 48 |
- `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
|
| 49 |
-
- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 部署时发送的 `X-App-Password-Hash`。
|
| 50 |
- `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。
|
| 51 |
- `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
|
| 52 |
- `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
|
| 53 |
|
|
|
|
|
|
|
| 54 |
脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。
|
| 55 |
|
| 56 |
## 参考
|
|
|
|
| 1 |
---
|
| 2 |
name: gpt-image-playground-agent
|
| 3 |
+
description: 当用户需要通过 API 调用已部署的 GPT Image Playground 批量请求图片生成时使用;支持文字生成图片、文字加图片生成图片,并返回可下载图片产物、metadata、base64 或 job 结果。
|
| 4 |
---
|
| 5 |
|
| 6 |
# GPT Image Playground Agent
|
|
|
|
| 11 |
|
| 12 |
1. 先定位服务基础地址。优先使用用户明确提供的 URL;其次使用 `GPT_IMAGE_PLAYGROUND_URL`;都没有时尝试默认地址 `http://localhost:4783`。
|
| 13 |
2. 用候选基础地址请求 `GET /api/agent/capabilities`。如果默认地址不可达、404、不是 JSON 或不是 Agent capabilities 响应,向用户询问实际部署地址、端口、域名和是否需要鉴权。
|
| 14 |
+
3. 读取 capabilities 中的认证方式、模型、模型级限制、Agent 流式边界、状态后端和端点路径;不要硬编码假设部署方式。
|
| 15 |
4. 为每个业务操作生成稳定的 `Idempotency-Key`。同一操作重试时复用原 key;不同操作不要复用。
|
| 16 |
+
5. 文生图使用 `POST /api/agent/images/generate`,请求体为 JSON。该 Agent 端点是非流式端点,当前固定以 `stream: false` 调上游。
|
| 17 |
+
6. 图片编辑使用 `POST /api/agent/images/edit`,请求体为 `multipart/form-data`,源图字段使用 `image_0..image_9`。该 Agent 端点同样是非流式端点。
|
| 18 |
7. 默认使用 `response_mode: "path"`,只在用户明确需要图片内联数据时使用 `base64` 或 `both`。
|
| 19 |
+
8. 不要把页面端 `POST /api/images` 当成 Agent 默认路径。它是页面表单和 SSE 路径,capabilities 会以 `agent_streaming.page_sse` 单独声明。
|
| 20 |
+
9. 读取 `agent_jobs`。若 `supported=true` 且 `mode=job_polling`,4K/high 或长耗时任务优先走 job/polling。
|
| 21 |
+
10. 处理失败时读取结构化 `error.code`、`error.retryable`、`error.diagnostics` 和 `Retry-After`。仅当 `retryable=true` 时等待后重试。
|
| 22 |
+
11. 返回结果时优先给出 `content_url`、`metadata_url`、`absolute_content_url`、`absolute_metadata_url`、产物 ID、尺寸、格式和是否命中幂等缓存。
|
| 23 |
|
| 24 |
## 鉴权
|
| 25 |
|
|
|
|
| 29 |
Authorization: Bearer <token>
|
| 30 |
```
|
| 31 |
|
| 32 |
+
此时服务端只接受 Bearer token,不会回退到访问码哈希。如果未配置 `AGENT_API_TOKEN` 但配置了页面访问码 `APP_PASSWORD`,发送 `X-App-Password-Hash`。下载或删除产物时必须复用 capabilities 声明的同一鉴权方式。
|
| 33 |
|
| 34 |
## 调用约束
|
| 35 |
|
| 36 |
+
- 不要把 API Key、token 或访问码写入源码、文档示例、日志或测试快照。
|
| 37 |
- 不要把 `localhost:4783` 当作唯一部署位置;它只是无明确地址时的探测默认值。
|
| 38 |
- 不要在模型上下文中展开大体积 base64,除非用户明确要求。
|
| 39 |
- 不要把 `error.message` 当成唯一判断依据;稳定分支以 `error.code` 和 HTTP 状态为准。
|
| 40 |
- 不要在没有 `Idempotency-Key` 的情况下调用生成或编辑接口。
|
| 41 |
+
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 42 |
+
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 支持流式;Agent generate/edit 当前以 `non_streaming_only` 声明。
|
| 43 |
+
- 不要调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`。
|
| 44 |
+
- 不要把一次高分辨率、高质量长耗时失败���纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
|
| 45 |
+
|
| 46 |
+
## Job Polling
|
| 47 |
+
|
| 48 |
+
当 `agent_jobs.supported=true` 时,长耗时文生图可使用:
|
| 49 |
+
|
| 50 |
+
1. `POST /api/agent/jobs/images/generate` 创建 job,仍必须提供 `Idempotency-Key`。
|
| 51 |
+
2. `GET /api/agent/jobs/{id}` 轮询状态。
|
| 52 |
+
3. `GET /api/agent/jobs/{id}/result` 在 `state=succeeded` 后读取标准 `AgentImageResponse`。
|
| 53 |
+
|
| 54 |
+
`GET /result` 在 job 运行中会返回 `request_in_progress` 和 `Retry-After`;不存在返回 `job_not_found`;过期返回 `job_expired`。同一业务操作重试创建 job 时复用原 `Idempotency-Key`,服务会返回同一个 job。
|
| 55 |
+
|
| 56 |
+
当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。若服务进程在 job 结束前重启,客户端应按状态和错误码继续轮询或重新创建同一 `Idempotency-Key` 的 job。若 job 已进入 `failed` 终态,`GET /result` 和状态摘要都会返回 `retryable=false`,并保留 `code`、`message`、`upstream_status` 和 `diagnostics` 用于定位原因,但同一个 key 不会触发新执行。需要重新尝试时,先确认失败原因,再以新的业务操作和新的 `Idempotency-Key` 创建 job。
|
| 57 |
|
| 58 |
## 可用脚本
|
| 59 |
|
| 60 |
+
- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实生图。
|
| 61 |
+
- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。默认 dry-run,不消耗额度;必须添加 `--allow-billable` 才会真实编辑。
|
| 62 |
+
- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 63 |
+
|
| 64 |
+
生成脚本常用参数:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
| 68 |
+
--size 2048x2048 \
|
| 69 |
+
--quality high \
|
| 70 |
+
--response-mode path \
|
| 71 |
+
--idempotency-key stable-operation-key \
|
| 72 |
+
"a product photo of a ceramic mug"
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
真实生图必须显式开启:
|
| 76 |
+
|
| 77 |
+
```bash
|
| 78 |
+
node skills/gpt-image-playground-agent/scripts/generate-image.mjs \
|
| 79 |
+
--allow-billable \
|
| 80 |
+
--timeout-ms 420000 \
|
| 81 |
+
--size 2048x2048 \
|
| 82 |
+
"a product photo of a ceramic mug"
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
生成脚本会在 capabilities 声明 `agent_jobs.supported=true` 后,对 `quality=high` 且最大边不小于 3072 的请求自动使用 job polling。也可以用 `--job` 强制 job polling,或用 `--no-job` 强制同步 Agent generate。
|
| 86 |
+
|
| 87 |
+
编辑脚本支持 `--model`、`--size`、`--quality`、`--response-mode`、`--timeout-ms`、`--idempotency-key`、`--dry-run` 和 `--allow-billable`。
|
| 88 |
+
|
| 89 |
+
直连上游诊断:
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
OPENAI_API_KEY=... node skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs \
|
| 93 |
+
--base-url https://api.openai.com/v1
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
诊断脚本只输出状态、耗时、脱敏错误摘要、白名单响应头和 base64 长度,不输出 API key 或完整图片数据。
|
| 97 |
+
|
| 98 |
+
上游探针脚本支持 `--base-url`、`--model`、`--prompt`、`--size`、`--quality`、`--format`、`--timeout-ms` 和 `--allow-billable`。默认读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL`,API Key 读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY`。上游 base URL 同样必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 URL。
|
| 99 |
|
| 100 |
脚本读取以下环境变量:
|
| 101 |
|
| 102 |
- `GPT_IMAGE_PLAYGROUND_URL`:服务基础地址,可指向本机、局域网、云服务器或域名;脚本未设置时默认尝试 `http://localhost:4783`。
|
| 103 |
- `GPT_IMAGE_AGENT_TOKEN`:Bearer token。
|
| 104 |
+
- `GPT_IMAGE_APP_PASSWORD_HASH`:使用 `APP_PASSWORD` 访问码部署时发送的 `X-App-Password-Hash`。
|
| 105 |
- `GPT_IMAGE_AGENT_IDEMPOTENCY_KEY`:跨脚本进程恢复同一操作时复用的幂等键。
|
| 106 |
- `GPT_IMAGE_AGENT_MAX_ATTEMPTS`:最大尝试次数,默认 `3`。
|
| 107 |
- `GPT_IMAGE_AGENT_CONTRACT_CHECK=1`:只检查 capabilities 和错误契约,不触发真实生图或编辑。
|
| 108 |
|
| 109 |
+
`GPT_IMAGE_PLAYGROUND_URL` 必须是无凭据、无查询参数、无片段的 `http`/`https` 绝对 base URL。不要把 token、访问码或其他 Secret 放进 URL。生成脚本轮询 job result 时只会携带鉴权头访问同 origin URL,避免异常服务返回外部 `result_url` 后泄露 Bearer token 或访问码哈希。
|
| 110 |
+
|
| 111 |
脚本会把服务返回的相对产物路径补充为绝对 URL,适合调用 Hugging Face Space、云服务器或自定义域名上的公网实例。
|
| 112 |
|
| 113 |
## 参考
|
skills/gpt-image-playground-agent/references/api.md
CHANGED
|
@@ -4,10 +4,52 @@
|
|
| 4 |
|
| 5 |
- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
|
| 6 |
- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
|
|
|
|
| 7 |
|
| 8 |
-
脚本
|
| 9 |
-
|
|
|
|
|
|
|
| 10 |
当服务返回相对 `content_url` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` 和 `absolute_metadata_url`。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
## 能力查询
|
| 13 |
|
|
@@ -15,7 +57,74 @@
|
|
| 15 |
GET /api/agent/capabilities
|
| 16 |
```
|
| 17 |
|
| 18 |
-
返回 API 版本、支持的模型、限制、鉴权方式、存储模式、状态后端、幂等设置和端点路径。响应不会公开服务端本地 SQLite 文件路径。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
## 生成图片
|
| 21 |
|
|
@@ -42,6 +151,8 @@ Content-Type: application/json
|
|
| 42 |
}
|
| 43 |
```
|
| 44 |
|
|
|
|
|
|
|
| 45 |
响应:
|
| 46 |
|
| 47 |
```json
|
|
@@ -112,11 +223,26 @@ DELETE /api/agent/artifacts/{id}
|
|
| 112 |
"n": "必须是 1 到 10 之间的整数"
|
| 113 |
}
|
| 114 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
"request_id": "uuid"
|
| 116 |
}
|
| 117 |
}
|
| 118 |
```
|
| 119 |
|
|
|
|
|
|
|
| 120 |
常见错误码:
|
| 121 |
|
| 122 |
- `validation_error`
|
|
@@ -126,6 +252,8 @@ DELETE /api/agent/artifacts/{id}
|
|
| 126 |
- `idempotency_conflict`
|
| 127 |
- `request_in_progress`
|
| 128 |
- `artifact_not_found`
|
|
|
|
|
|
|
| 129 |
- `upstream_rate_limited`
|
| 130 |
- `upstream_auth_failed`
|
| 131 |
- `upstream_unavailable`
|
|
|
|
| 4 |
|
| 5 |
- `skills/gpt-image-playground-agent/scripts/generate-image.mjs`:JSON 文生图调用。
|
| 6 |
- `skills/gpt-image-playground-agent/scripts/edit-image.mjs`:multipart 编辑调用。
|
| 7 |
+
- `skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
| 8 |
|
| 9 |
+
生成和编辑脚本默认只做 dry-run,不触发真实生图或编辑。必须显式添加 `--allow-billable` 才会调用 `/api/agent/images/generate` 或 `/api/agent/images/edit`。
|
| 10 |
+
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
| 11 |
+
脚本支持 `GPT_IMAGE_AGENT_CONTRACT_CHECK=1` 或 `--contract-check` 做只读契约检查,不触发真实生图或编辑。
|
| 12 |
+
鉴权以 capabilities 的 `auth.schemes` 为准。配置 `AGENT_API_TOKEN` 时只接受 Bearer token;只有未配置 `AGENT_API_TOKEN` 且配置了 `APP_PASSWORD` 时,才接受访问码哈希 `GPT_IMAGE_APP_PASSWORD_HASH`。
|
| 13 |
当服务返回相对 `content_url` 或 `metadata_url` 时,辅助脚本会额外输出 `absolute_content_url` 和 `absolute_metadata_url`。
|
| 14 |
+
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 15 |
+
|
| 16 |
+
生成脚本参数:
|
| 17 |
+
|
| 18 |
+
- `--model`:默认 `gpt-image-2`。
|
| 19 |
+
- `--size`:默认 `1024x1024`。
|
| 20 |
+
- `--quality`:默认 `high`。
|
| 21 |
+
- `--n`:默认 `1`。
|
| 22 |
+
- `--format`:默认 `png`,`jpg` 会规范化为 `jpeg`。
|
| 23 |
+
- `--response-mode`:默认 `path`。
|
| 24 |
+
- `--timeout-ms`:默认 `420000`。
|
| 25 |
+
- `--prompt-file`:从文本文件读取 prompt。
|
| 26 |
+
- `--idempotency-key`:指定稳定幂等键。
|
| 27 |
+
- `--dry-run`:只输出将要发送的 JSON。
|
| 28 |
+
- `--allow-billable`:允许真实调用生图端点。
|
| 29 |
+
|
| 30 |
+
编辑脚本参数:
|
| 31 |
+
|
| 32 |
+
- `--model`
|
| 33 |
+
- `--size`
|
| 34 |
+
- `--quality`
|
| 35 |
+
- `--response-mode`
|
| 36 |
+
- `--timeout-ms`
|
| 37 |
+
- `--idempotency-key`
|
| 38 |
+
- `--dry-run`
|
| 39 |
+
- `--allow-billable`
|
| 40 |
+
|
| 41 |
+
上游探针脚本参数:
|
| 42 |
+
|
| 43 |
+
- `--base-url`
|
| 44 |
+
- `--model`
|
| 45 |
+
- `--prompt`
|
| 46 |
+
- `--size`
|
| 47 |
+
- `--quality`
|
| 48 |
+
- `--format`
|
| 49 |
+
- `--timeout-ms`
|
| 50 |
+
- `--allow-billable`
|
| 51 |
+
|
| 52 |
+
上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。输出不会包含 key,也不会输出完整 base64。
|
| 53 |
|
| 54 |
## 能力查询
|
| 55 |
|
|
|
|
| 57 |
GET /api/agent/capabilities
|
| 58 |
```
|
| 59 |
|
| 60 |
+
返回 API 版本、支持的模型、通用限制、模型级限制、Agent 流式边界、鉴权方式、存储模式、状态后端、幂等设置和端点路径。响应不会公开服务端本地 SQLite 文件路径。
|
| 61 |
+
|
| 62 |
+
关键字段:
|
| 63 |
+
|
| 64 |
+
- `auth.required`:是否需要鉴权。
|
| 65 |
+
- `auth.schemes`:当前部署实际接受的鉴权方案。`AGENT_API_TOKEN` 优先于 `APP_PASSWORD`,两者同时配置时只返回 `bearer`。
|
| 66 |
+
- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
|
| 67 |
+
- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
|
| 68 |
+
- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
|
| 69 |
+
- `model_limits.gpt-image-2.max_aspect`:最大长短边比例,当前为 `3`。
|
| 70 |
+
- `model_limits.gpt-image-2.min_pixels`:最小总像素,当前为 `655360`。
|
| 71 |
+
- `model_limits.gpt-image-2.recommended_presets`:推荐尺寸预设。
|
| 72 |
+
- `model_limits.gpt-image-2.high_4k_risk`:高质量 4K 级请求的长耗时风险说明。
|
| 73 |
+
- `agent_streaming.generate.mode`:当前为 `non_streaming_only`。
|
| 74 |
+
- `agent_streaming.edit.mode`:当前为 `non_streaming_only`。
|
| 75 |
+
- `agent_streaming.page_sse`:页面端 `/api/images` 的 form-data SSE 能力,不代表 Agent generate/edit 支持流式。
|
| 76 |
+
- `agent_jobs.supported`:当前为 `true`,表示可使用 job polling。
|
| 77 |
+
- `agent_jobs.mode`:当前为 `job_polling`。
|
| 78 |
+
- `agent_jobs.endpoints`:路径为 `POST /api/agent/jobs/images/generate`、`GET /api/agent/jobs/{id}`、`GET /api/agent/jobs/{id}/result`。
|
| 79 |
+
- `agent_jobs.states`:状态机为 `queued`、`running`、`succeeded`、`failed`、`expired`。
|
| 80 |
+
|
| 81 |
+
当 `agent_jobs.supported=true` 且 `mode=job_polling` 时,4K/high 长耗时请求优先创建 job 并轮询结果;同步 Agent generate 仍适用于普通非流式请求。当前 job polling 是同一服务实例内的后台任务,结果和错误写入 Agent 状态后端;它不是跨实例持久队列。
|
| 82 |
+
|
| 83 |
+
## Job Polling
|
| 84 |
+
|
| 85 |
+
```http
|
| 86 |
+
POST /api/agent/jobs/images/generate
|
| 87 |
+
Authorization: Bearer <token>
|
| 88 |
+
Idempotency-Key: <stable-key>
|
| 89 |
+
Content-Type: application/json
|
| 90 |
+
```
|
| 91 |
+
|
| 92 |
+
请求体与 `POST /api/agent/images/generate` 相同。创建成功后返回:
|
| 93 |
+
|
| 94 |
+
```json
|
| 95 |
+
{
|
| 96 |
+
"job": {
|
| 97 |
+
"id": "job-request-uuid",
|
| 98 |
+
"request_id": "job-request-uuid",
|
| 99 |
+
"idempotency_key": "stable-key",
|
| 100 |
+
"mode": "generate",
|
| 101 |
+
"state": "running",
|
| 102 |
+
"created_at": "2026-05-20T00:00:00.000Z",
|
| 103 |
+
"updated_at": "2026-05-20T00:00:00.000Z",
|
| 104 |
+
"expires_at": "2026-05-21T00:00:00.000Z",
|
| 105 |
+
"result_url": "/api/agent/jobs/job-request-uuid/result",
|
| 106 |
+
"retry_after_seconds": 5
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
```
|
| 110 |
+
|
| 111 |
+
轮询状态:
|
| 112 |
+
|
| 113 |
+
```http
|
| 114 |
+
GET /api/agent/jobs/{id}
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
读取结果:
|
| 118 |
+
|
| 119 |
+
```http
|
| 120 |
+
GET /api/agent/jobs/{id}/result
|
| 121 |
+
```
|
| 122 |
+
|
| 123 |
+
`/result` 在运行中返回 `request_in_progress` 和 `Retry-After`;成功后返回标准 `AgentImageResponse`;失败时返回结构化 `AgentError`。失败 job 是终态,`error.retryable` 固定为 `false`,但保留原始错误的 `code`、`message`、`upstream_status` 和 `diagnostics` 用于排查。不存在返回 `job_not_found`,过期返回 `job_expired`。
|
| 124 |
+
|
| 125 |
+
`GET /api/agent/jobs/{id}` 在 `state=failed` 时,`job.error` 也会返回 `retryable=false`,并携带同样的 `code`、`message`、`upstream_status` 和 `diagnostics` 排障字段;`request_id` 已在 `job.request_id` 中提供。
|
| 126 |
+
|
| 127 |
+
如果服务进程在 job 结束前重启,客户端应按 `GET /api/agent/jobs/{id}` 返回的状态继续处理;必要时使用相同 `Idempotency-Key` 重新创建同一 job,避免重复业务操作。同一个 key 命中终态 failed job 时只会返回该失败状态,不会触发新执行;需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 128 |
|
| 129 |
## 生成图片
|
| 130 |
|
|
|
|
| 151 |
}
|
| 152 |
```
|
| 153 |
|
| 154 |
+
Agent 生成端点当前只支持非流式 JSON 响应。不要向该端点发送 `stream: true`;页面 SSE 使用独立的 `POST /api/images` form-data 路径。
|
| 155 |
+
|
| 156 |
响应:
|
| 157 |
|
| 158 |
```json
|
|
|
|
| 223 |
"n": "必须是 1 到 10 之间的整数"
|
| 224 |
}
|
| 225 |
},
|
| 226 |
+
"diagnostics": {
|
| 227 |
+
"elapsed_ms": 1234,
|
| 228 |
+
"selected_channel_id": "default",
|
| 229 |
+
"upstream_host": "api.example.test",
|
| 230 |
+
"upstream_status": 524,
|
| 231 |
+
"transport_error": false,
|
| 232 |
+
"retry_after_seconds": 15,
|
| 233 |
+
"channel_cooldown_scope": "channel",
|
| 234 |
+
"response_headers": {
|
| 235 |
+
"date": "Wed, 20 May 2026 00:00:00 GMT",
|
| 236 |
+
"cf-ray": "example"
|
| 237 |
+
}
|
| 238 |
+
},
|
| 239 |
"request_id": "uuid"
|
| 240 |
}
|
| 241 |
}
|
| 242 |
```
|
| 243 |
|
| 244 |
+
`diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。
|
| 245 |
+
|
| 246 |
常见错误码:
|
| 247 |
|
| 248 |
- `validation_error`
|
|
|
|
| 252 |
- `idempotency_conflict`
|
| 253 |
- `request_in_progress`
|
| 254 |
- `artifact_not_found`
|
| 255 |
+
- `job_not_found`
|
| 256 |
+
- `job_expired`
|
| 257 |
- `upstream_rate_limited`
|
| 258 |
- `upstream_auth_failed`
|
| 259 |
- `upstream_unavailable`
|
skills/gpt-image-playground-agent/scripts/edit-image.mjs
CHANGED
|
@@ -2,33 +2,123 @@
|
|
| 2 |
import crypto from 'node:crypto';
|
| 3 |
import fs from 'node:fs';
|
| 4 |
import path from 'node:path';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
|
| 7 |
const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
|
| 8 |
const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
|
| 9 |
-
const
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
if ((!imagePath || !prompt) && !contractCheck) {
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
process.exit(2);
|
| 20 |
}
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
function authHeaders() {
|
| 23 |
if (token) return { Authorization: `Bearer ${token}` };
|
| 24 |
if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
|
| 25 |
return {};
|
| 26 |
}
|
| 27 |
|
| 28 |
-
function normalizeBaseUrl(value) {
|
| 29 |
-
return value.replace(/\/+$/, '');
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
function absoluteUrl(value) {
|
| 33 |
if (typeof value !== 'string' || !value) return undefined;
|
| 34 |
return new URL(value, `${baseUrl}/`).toString();
|
|
@@ -49,11 +139,11 @@ function enrichImageUrls(result) {
|
|
| 49 |
async function readCapabilities() {
|
| 50 |
let response;
|
| 51 |
try {
|
| 52 |
-
response = await
|
| 53 |
headers: authHeaders()
|
| 54 |
});
|
| 55 |
} catch (error) {
|
| 56 |
-
const message =
|
| 57 |
throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
|
| 58 |
}
|
| 59 |
if (!response.ok) {
|
|
@@ -63,28 +153,36 @@ async function readCapabilities() {
|
|
| 63 |
return response.json();
|
| 64 |
}
|
| 65 |
|
| 66 |
-
function parseRetryAfterValue(value) {
|
| 67 |
-
if (!value || !/^\d+$/.test(value)) return 1;
|
| 68 |
-
return Math.max(1, Number(value));
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
function shouldRetry(result) {
|
| 72 |
return Boolean(result?.error?.retryable);
|
| 73 |
}
|
| 74 |
|
| 75 |
-
function
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
|
| 79 |
try {
|
| 80 |
await readCapabilities();
|
| 81 |
} catch (error) {
|
| 82 |
-
console.error(
|
| 83 |
process.exit(1);
|
| 84 |
}
|
| 85 |
|
| 86 |
if (contractCheck) {
|
| 87 |
-
const response = await
|
| 88 |
method: 'POST',
|
| 89 |
headers: {
|
| 90 |
'Idempotency-Key': idempotencyKey,
|
|
@@ -95,10 +193,10 @@ if (contractCheck) {
|
|
| 95 |
});
|
| 96 |
const result = await response.json();
|
| 97 |
if (response.status === 415 && result?.error?.code === 'validation_error') {
|
| 98 |
-
console.log(JSON.stringify({ ok: true, status: response.status, error_code: result.error.code }, null, 2));
|
| 99 |
process.exit(0);
|
| 100 |
}
|
| 101 |
-
console.error(JSON.stringify({ ok: false, status: response.status, result }, null, 2));
|
| 102 |
process.exit(1);
|
| 103 |
}
|
| 104 |
|
|
@@ -109,7 +207,7 @@ try {
|
|
| 109 |
process.exit(2);
|
| 110 |
}
|
| 111 |
} catch (error) {
|
| 112 |
-
const message =
|
| 113 |
console.error(`无法读取图片文件:${imagePath}。${message}`);
|
| 114 |
process.exit(2);
|
| 115 |
}
|
|
@@ -120,8 +218,10 @@ const imageType = mimeTypeForPath(imagePath);
|
|
| 120 |
function buildFormData() {
|
| 121 |
const formData = new FormData();
|
| 122 |
formData.append('prompt', prompt);
|
| 123 |
-
formData.append('model',
|
| 124 |
-
formData.append('
|
|
|
|
|
|
|
| 125 |
formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
|
| 126 |
return formData;
|
| 127 |
}
|
|
@@ -140,7 +240,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
| 140 |
let response;
|
| 141 |
let result;
|
| 142 |
try {
|
| 143 |
-
response = await
|
| 144 |
method: 'POST',
|
| 145 |
headers: {
|
| 146 |
'Idempotency-Key': idempotencyKey,
|
|
@@ -150,7 +250,7 @@ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
| 150 |
});
|
| 151 |
result = await response.json();
|
| 152 |
} catch (error) {
|
| 153 |
-
const message =
|
| 154 |
result = { error: { code: 'network_error', message, retryable: true } };
|
| 155 |
lastResult = result;
|
| 156 |
lastRetryAfter = 1;
|
|
|
|
| 2 |
import crypto from 'node:crypto';
|
| 3 |
import fs from 'node:fs';
|
| 4 |
import path from 'node:path';
|
| 5 |
+
import {
|
| 6 |
+
errorMessage,
|
| 7 |
+
normalizeBaseUrl,
|
| 8 |
+
parseRetryAfterValue,
|
| 9 |
+
readConfiguredPositiveInteger,
|
| 10 |
+
readOptionValue,
|
| 11 |
+
sleep
|
| 12 |
+
} from './lib/script-utils.mjs';
|
| 13 |
|
|
|
|
| 14 |
const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
|
| 15 |
const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
|
| 16 |
+
const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
|
| 17 |
+
let options;
|
| 18 |
+
try {
|
| 19 |
+
options = parseArgs(process.argv.slice(2));
|
| 20 |
+
} catch (error) {
|
| 21 |
+
console.error(errorMessage(error));
|
| 22 |
+
printUsage();
|
| 23 |
+
process.exit(2);
|
| 24 |
+
}
|
| 25 |
+
const imagePath = options.imagePath;
|
| 26 |
+
const prompt = options.promptParts.join(' ');
|
| 27 |
+
if (options.help) {
|
| 28 |
+
printUsage();
|
| 29 |
+
process.exit(0);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
let maxAttempts;
|
| 33 |
+
let timeoutMs;
|
| 34 |
+
try {
|
| 35 |
+
maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3);
|
| 36 |
+
timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
|
| 37 |
+
} catch (error) {
|
| 38 |
+
console.error(errorMessage(error));
|
| 39 |
+
printUsage();
|
| 40 |
+
process.exit(2);
|
| 41 |
+
}
|
| 42 |
+
const idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-edit-${crypto.randomUUID()}`;
|
| 43 |
|
| 44 |
if ((!imagePath || !prompt) && !contractCheck) {
|
| 45 |
+
printUsage();
|
| 46 |
+
process.exit(2);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
let baseUrl;
|
| 50 |
+
try {
|
| 51 |
+
baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
|
| 52 |
+
} catch (error) {
|
| 53 |
+
console.error(errorMessage(error));
|
| 54 |
process.exit(2);
|
| 55 |
}
|
| 56 |
|
| 57 |
+
if (options.dryRun || (!contractCheck && !options.allowBillable)) {
|
| 58 |
+
console.log(
|
| 59 |
+
JSON.stringify(
|
| 60 |
+
{
|
| 61 |
+
ok: true,
|
| 62 |
+
billable: false,
|
| 63 |
+
dry_run: true,
|
| 64 |
+
endpoint: `${baseUrl}/api/agent/images/edit`,
|
| 65 |
+
idempotency_key: idempotencyKey,
|
| 66 |
+
request: {
|
| 67 |
+
image_path: imagePath,
|
| 68 |
+
prompt,
|
| 69 |
+
model: options.model,
|
| 70 |
+
size: options.size,
|
| 71 |
+
quality: options.quality,
|
| 72 |
+
response_mode: options.responseMode
|
| 73 |
+
},
|
| 74 |
+
next_step: '重新执行并添加 --allow-billable 才会发起真实图片编辑请求。'
|
| 75 |
+
},
|
| 76 |
+
null,
|
| 77 |
+
2
|
| 78 |
+
)
|
| 79 |
+
);
|
| 80 |
+
process.exit(0);
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function parseArgs(argv) {
|
| 84 |
+
const parsed = {
|
| 85 |
+
model: 'gpt-image-2',
|
| 86 |
+
size: 'auto',
|
| 87 |
+
quality: 'auto',
|
| 88 |
+
responseMode: 'path',
|
| 89 |
+
timeoutMs: undefined,
|
| 90 |
+
idempotencyKey: undefined,
|
| 91 |
+
imagePath: undefined,
|
| 92 |
+
dryRun: false,
|
| 93 |
+
allowBillable: false,
|
| 94 |
+
help: false,
|
| 95 |
+
promptParts: []
|
| 96 |
+
};
|
| 97 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 98 |
+
const arg = argv[index];
|
| 99 |
+
if (arg === '--dry-run') parsed.dryRun = true;
|
| 100 |
+
else if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 101 |
+
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 102 |
+
else if (arg === '--contract-check') continue;
|
| 103 |
+
else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
|
| 104 |
+
else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
|
| 105 |
+
else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
|
| 106 |
+
else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
|
| 107 |
+
else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
|
| 108 |
+
else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
|
| 109 |
+
else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
|
| 110 |
+
else if (!parsed.imagePath) parsed.imagePath = arg;
|
| 111 |
+
else parsed.promptParts.push(arg);
|
| 112 |
+
}
|
| 113 |
+
return parsed;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
function authHeaders() {
|
| 117 |
if (token) return { Authorization: `Bearer ${token}` };
|
| 118 |
if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
|
| 119 |
return {};
|
| 120 |
}
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
function absoluteUrl(value) {
|
| 123 |
if (typeof value !== 'string' || !value) return undefined;
|
| 124 |
return new URL(value, `${baseUrl}/`).toString();
|
|
|
|
| 139 |
async function readCapabilities() {
|
| 140 |
let response;
|
| 141 |
try {
|
| 142 |
+
response = await fetchWithTimeout(`${baseUrl}/api/agent/capabilities`, {
|
| 143 |
headers: authHeaders()
|
| 144 |
});
|
| 145 |
} catch (error) {
|
| 146 |
+
const message = errorMessage(error);
|
| 147 |
throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
|
| 148 |
}
|
| 149 |
if (!response.ok) {
|
|
|
|
| 153 |
return response.json();
|
| 154 |
}
|
| 155 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
function shouldRetry(result) {
|
| 157 |
return Boolean(result?.error?.retryable);
|
| 158 |
}
|
| 159 |
|
| 160 |
+
async function fetchWithTimeout(url, init) {
|
| 161 |
+
const controller = new AbortController();
|
| 162 |
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
| 163 |
+
try {
|
| 164 |
+
return await fetch(url, { ...init, signal: controller.signal });
|
| 165 |
+
} finally {
|
| 166 |
+
clearTimeout(timeout);
|
| 167 |
+
}
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
function printUsage() {
|
| 171 |
+
console.error('用法:edit-image.mjs [options] <image-path> <prompt>');
|
| 172 |
+
console.error('默认只输出 dry-run;添加 --allow-billable 才会真实编辑图片。');
|
| 173 |
+
console.error('常用参数:--model --size --quality --response-mode --timeout-ms --idempotency-key --dry-run --allow-billable');
|
| 174 |
+
console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 edit-image.mjs 或 edit-image.mjs --contract-check');
|
| 175 |
}
|
| 176 |
|
| 177 |
try {
|
| 178 |
await readCapabilities();
|
| 179 |
} catch (error) {
|
| 180 |
+
console.error(errorMessage(error));
|
| 181 |
process.exit(1);
|
| 182 |
}
|
| 183 |
|
| 184 |
if (contractCheck) {
|
| 185 |
+
const response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
|
| 186 |
method: 'POST',
|
| 187 |
headers: {
|
| 188 |
'Idempotency-Key': idempotencyKey,
|
|
|
|
| 193 |
});
|
| 194 |
const result = await response.json();
|
| 195 |
if (response.status === 415 && result?.error?.code === 'validation_error') {
|
| 196 |
+
console.log(JSON.stringify({ ok: true, billable: false, status: response.status, error_code: result.error.code }, null, 2));
|
| 197 |
process.exit(0);
|
| 198 |
}
|
| 199 |
+
console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
|
| 200 |
process.exit(1);
|
| 201 |
}
|
| 202 |
|
|
|
|
| 207 |
process.exit(2);
|
| 208 |
}
|
| 209 |
} catch (error) {
|
| 210 |
+
const message = errorMessage(error);
|
| 211 |
console.error(`无法读取图片文件:${imagePath}。${message}`);
|
| 212 |
process.exit(2);
|
| 213 |
}
|
|
|
|
| 218 |
function buildFormData() {
|
| 219 |
const formData = new FormData();
|
| 220 |
formData.append('prompt', prompt);
|
| 221 |
+
formData.append('model', options.model);
|
| 222 |
+
formData.append('size', options.size);
|
| 223 |
+
formData.append('quality', options.quality);
|
| 224 |
+
formData.append('response_mode', options.responseMode);
|
| 225 |
formData.append('image_0', new Blob([imageBuffer], { type: imageType }), path.basename(imagePath));
|
| 226 |
return formData;
|
| 227 |
}
|
|
|
|
| 240 |
let response;
|
| 241 |
let result;
|
| 242 |
try {
|
| 243 |
+
response = await fetchWithTimeout(`${baseUrl}/api/agent/images/edit`, {
|
| 244 |
method: 'POST',
|
| 245 |
headers: {
|
| 246 |
'Idempotency-Key': idempotencyKey,
|
|
|
|
| 250 |
});
|
| 251 |
result = await response.json();
|
| 252 |
} catch (error) {
|
| 253 |
+
const message = errorMessage(error);
|
| 254 |
result = { error: { code: 'network_error', message, retryable: true } };
|
| 255 |
lastResult = result;
|
| 256 |
lastRetryAfter = 1;
|
skills/gpt-image-playground-agent/scripts/generate-image.mjs
CHANGED
|
@@ -1,29 +1,205 @@
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
import crypto from 'node:crypto';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
-
const baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
|
| 5 |
const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
|
| 6 |
const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
|
| 7 |
-
const
|
| 8 |
-
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
process.exit(2);
|
| 15 |
}
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
function authHeaders() {
|
| 18 |
if (token) return { Authorization: `Bearer ${token}` };
|
| 19 |
if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
|
| 20 |
return {};
|
| 21 |
}
|
| 22 |
|
| 23 |
-
function normalizeBaseUrl(value) {
|
| 24 |
-
return value.replace(/\/+$/, '');
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
function absoluteUrl(value) {
|
| 28 |
if (typeof value !== 'string' || !value) return undefined;
|
| 29 |
return new URL(value, `${baseUrl}/`).toString();
|
|
@@ -41,97 +217,218 @@ function enrichImageUrls(result) {
|
|
| 41 |
};
|
| 42 |
}
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
async function readCapabilities() {
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
});
|
| 50 |
-
} catch (error) {
|
| 51 |
-
const message = error instanceof Error ? error.message : String(error);
|
| 52 |
-
throw new Error(`无法连接 GPT Image Playground:${baseUrl}。${message}`);
|
| 53 |
-
}
|
| 54 |
if (!response.ok) {
|
| 55 |
-
|
| 56 |
-
throw new Error(`capabilities 请求失败,状态码 ${response.status}:${body}`);
|
| 57 |
}
|
| 58 |
-
return
|
| 59 |
}
|
| 60 |
|
| 61 |
-
function
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
return Math.max(1, Number(value));
|
| 65 |
-
}
|
| 66 |
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
}
|
| 70 |
|
| 71 |
-
function
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
}
|
| 74 |
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
process.exit(1);
|
| 80 |
}
|
| 81 |
|
| 82 |
-
|
| 83 |
-
const
|
|
|
|
| 84 |
method: 'POST',
|
| 85 |
headers: {
|
| 86 |
'Content-Type': 'application/json',
|
| 87 |
...authHeaders()
|
| 88 |
},
|
| 89 |
-
body: JSON.stringify(
|
| 90 |
-
|
| 91 |
-
model: 'gpt-image-2',
|
| 92 |
-
response_mode: 'path'
|
| 93 |
-
})
|
| 94 |
});
|
| 95 |
-
const result = await response.json();
|
| 96 |
if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
|
| 97 |
-
|
| 98 |
-
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
-
|
| 101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
}
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
if (!
|
| 133 |
-
|
| 134 |
}
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
#!/usr/bin/env node
|
| 2 |
import crypto from 'node:crypto';
|
| 3 |
+
import fs from 'node:fs';
|
| 4 |
+
import {
|
| 5 |
+
errorMessage,
|
| 6 |
+
normalizeBaseUrl,
|
| 7 |
+
normalizeOutputFormat,
|
| 8 |
+
parseRetryAfterValue,
|
| 9 |
+
readConfiguredPositiveInteger,
|
| 10 |
+
readOptionValue,
|
| 11 |
+
resolveSameOriginUrl,
|
| 12 |
+
sleep
|
| 13 |
+
} from './lib/script-utils.mjs';
|
| 14 |
|
|
|
|
| 15 |
const token = process.env.GPT_IMAGE_AGENT_TOKEN || '';
|
| 16 |
const passwordHash = process.env.GPT_IMAGE_APP_PASSWORD_HASH || '';
|
| 17 |
+
const contractCheck = process.env.GPT_IMAGE_AGENT_CONTRACT_CHECK === '1' || process.argv.includes('--contract-check');
|
| 18 |
+
let options;
|
| 19 |
+
try {
|
| 20 |
+
options = parseArgs(process.argv.slice(2));
|
| 21 |
+
} catch (error) {
|
| 22 |
+
console.error(errorMessage(error));
|
| 23 |
+
printUsage();
|
| 24 |
+
process.exit(2);
|
| 25 |
+
}
|
| 26 |
+
if (options.help) {
|
| 27 |
+
printUsage();
|
| 28 |
+
process.exit(0);
|
| 29 |
+
}
|
| 30 |
|
| 31 |
+
let prompt;
|
| 32 |
+
let maxAttempts;
|
| 33 |
+
let timeoutMs;
|
| 34 |
+
let idempotencyKey;
|
| 35 |
+
let requestBody;
|
| 36 |
+
try {
|
| 37 |
+
maxAttempts = readConfiguredPositiveInteger(process.env.GPT_IMAGE_AGENT_MAX_ATTEMPTS, 'GPT_IMAGE_AGENT_MAX_ATTEMPTS', 3);
|
| 38 |
+
timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 420000);
|
| 39 |
+
idempotencyKey = options.idempotencyKey || process.env.GPT_IMAGE_AGENT_IDEMPOTENCY_KEY || `agent-generate-${crypto.randomUUID()}`;
|
| 40 |
+
if (isNonBillableDryRun(options, contractCheck)) {
|
| 41 |
+
if (!hasPromptSource(options)) {
|
| 42 |
+
printUsage();
|
| 43 |
+
process.exit(2);
|
| 44 |
+
}
|
| 45 |
+
requestBody = buildDryRunRequestBody(options);
|
| 46 |
+
} else {
|
| 47 |
+
prompt = readPrompt(options, { readPromptFile: !contractCheck });
|
| 48 |
+
requestBody = buildRequestBody(prompt, options);
|
| 49 |
+
}
|
| 50 |
+
} catch (error) {
|
| 51 |
+
console.error(errorMessage(error));
|
| 52 |
+
printUsage();
|
| 53 |
process.exit(2);
|
| 54 |
}
|
| 55 |
|
| 56 |
+
if (!isNonBillableDryRun(options, contractCheck) && !prompt && !contractCheck) {
|
| 57 |
+
printUsage();
|
| 58 |
+
process.exit(2);
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
let baseUrl;
|
| 62 |
+
try {
|
| 63 |
+
baseUrl = normalizeBaseUrl(process.env.GPT_IMAGE_PLAYGROUND_URL || 'http://localhost:4783');
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.error(errorMessage(error));
|
| 66 |
+
process.exit(2);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
if (isNonBillableDryRun(options, contractCheck)) {
|
| 70 |
+
console.log(
|
| 71 |
+
JSON.stringify(
|
| 72 |
+
{
|
| 73 |
+
ok: true,
|
| 74 |
+
billable: false,
|
| 75 |
+
dry_run: true,
|
| 76 |
+
endpoint: dryRunEndpoint(options.jobMode),
|
| 77 |
+
job_mode: options.jobMode,
|
| 78 |
+
idempotency_key: idempotencyKey,
|
| 79 |
+
request: requestBody,
|
| 80 |
+
next_step: '重新执行并添加 --allow-billable 才会发起真实生图请求。'
|
| 81 |
+
},
|
| 82 |
+
null,
|
| 83 |
+
2
|
| 84 |
+
)
|
| 85 |
+
);
|
| 86 |
+
process.exit(0);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
try {
|
| 90 |
+
var capabilities = await readCapabilities();
|
| 91 |
+
} catch (error) {
|
| 92 |
+
console.error(errorMessage(error));
|
| 93 |
+
process.exit(1);
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
if (contractCheck) {
|
| 97 |
+
await runContractCheck(capabilities);
|
| 98 |
+
process.exit(0);
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
try {
|
| 102 |
+
if (shouldUseJobPolling(capabilities, requestBody, options.jobMode)) {
|
| 103 |
+
await runGenerateJob();
|
| 104 |
+
} else {
|
| 105 |
+
await runGenerateRequest();
|
| 106 |
+
}
|
| 107 |
+
} catch (error) {
|
| 108 |
+
console.error(errorMessage(error));
|
| 109 |
+
process.exit(1);
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
function parseArgs(argv) {
|
| 113 |
+
const parsed = {
|
| 114 |
+
model: 'gpt-image-2',
|
| 115 |
+
size: '1024x1024',
|
| 116 |
+
quality: 'high',
|
| 117 |
+
n: '1',
|
| 118 |
+
format: 'png',
|
| 119 |
+
responseMode: 'path',
|
| 120 |
+
timeoutMs: undefined,
|
| 121 |
+
promptFile: undefined,
|
| 122 |
+
idempotencyKey: undefined,
|
| 123 |
+
jobMode: 'auto',
|
| 124 |
+
dryRun: false,
|
| 125 |
+
allowBillable: false,
|
| 126 |
+
help: false,
|
| 127 |
+
promptParts: []
|
| 128 |
+
};
|
| 129 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 130 |
+
const arg = argv[index];
|
| 131 |
+
if (arg === '--dry-run') parsed.dryRun = true;
|
| 132 |
+
else if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 133 |
+
else if (arg === '--job') parsed.jobMode = 'always';
|
| 134 |
+
else if (arg === '--no-job') parsed.jobMode = 'never';
|
| 135 |
+
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 136 |
+
else if (arg === '--contract-check') continue;
|
| 137 |
+
else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
|
| 138 |
+
else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
|
| 139 |
+
else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
|
| 140 |
+
else if (arg === '--n') parsed.n = readOptionValue(argv, (index += 1), arg);
|
| 141 |
+
else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
|
| 142 |
+
else if (arg === '--response-mode') parsed.responseMode = readOptionValue(argv, (index += 1), arg);
|
| 143 |
+
else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
|
| 144 |
+
else if (arg === '--prompt-file') parsed.promptFile = readOptionValue(argv, (index += 1), arg);
|
| 145 |
+
else if (arg === '--idempotency-key') parsed.idempotencyKey = readOptionValue(argv, (index += 1), arg);
|
| 146 |
+
else if (arg.startsWith('--')) throw new Error(`未知参数:${arg}`);
|
| 147 |
+
else parsed.promptParts.push(arg);
|
| 148 |
+
}
|
| 149 |
+
return parsed;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function readPrompt(parsed, { readPromptFile }) {
|
| 153 |
+
if (parsed.promptFile) {
|
| 154 |
+
if (readPromptFile) {
|
| 155 |
+
return fs.readFileSync(parsed.promptFile, 'utf8');
|
| 156 |
+
}
|
| 157 |
+
return parsed.promptParts.join(' ') || 'contract check';
|
| 158 |
+
}
|
| 159 |
+
return parsed.promptParts.join(' ');
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function buildRequestBody(promptValue, parsed) {
|
| 163 |
+
return {
|
| 164 |
+
prompt: promptValue || 'contract check',
|
| 165 |
+
model: parsed.model,
|
| 166 |
+
n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
|
| 167 |
+
size: parsed.size,
|
| 168 |
+
quality: parsed.quality,
|
| 169 |
+
output_format: normalizeOutputFormat(parsed.format),
|
| 170 |
+
response_mode: parsed.responseMode
|
| 171 |
+
};
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function buildDryRunRequestBody(parsed) {
|
| 175 |
+
const body = {
|
| 176 |
+
model: parsed.model,
|
| 177 |
+
n: readConfiguredPositiveInteger(parsed.n, '--n', 1),
|
| 178 |
+
size: parsed.size,
|
| 179 |
+
quality: parsed.quality,
|
| 180 |
+
output_format: normalizeOutputFormat(parsed.format),
|
| 181 |
+
response_mode: parsed.responseMode
|
| 182 |
+
};
|
| 183 |
+
if (parsed.promptFile) {
|
| 184 |
+
return { ...body, prompt_file: parsed.promptFile };
|
| 185 |
+
}
|
| 186 |
+
return { ...body, prompt: parsed.promptParts.join(' ') };
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
function hasPromptSource(parsed) {
|
| 190 |
+
return Boolean(parsed.promptFile || parsed.promptParts.length > 0);
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
function isNonBillableDryRun(parsed, isContractCheck) {
|
| 194 |
+
return parsed.dryRun || (!isContractCheck && !parsed.allowBillable);
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
function authHeaders() {
|
| 198 |
if (token) return { Authorization: `Bearer ${token}` };
|
| 199 |
if (passwordHash) return { 'X-App-Password-Hash': passwordHash };
|
| 200 |
return {};
|
| 201 |
}
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
function absoluteUrl(value) {
|
| 204 |
if (typeof value !== 'string' || !value) return undefined;
|
| 205 |
return new URL(value, `${baseUrl}/`).toString();
|
|
|
|
| 217 |
};
|
| 218 |
}
|
| 219 |
|
| 220 |
+
function dryRunEndpoint(jobMode) {
|
| 221 |
+
if (jobMode === 'always') return `${baseUrl}/api/agent/jobs/images/generate`;
|
| 222 |
+
if (jobMode === 'never') return `${baseUrl}/api/agent/images/generate`;
|
| 223 |
+
return `${baseUrl}/api/agent/images/generate 或 ${baseUrl}/api/agent/jobs/images/generate`;
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
async function readCapabilities() {
|
| 227 |
+
const { response, result, text } = await fetchJson(`${baseUrl}/api/agent/capabilities`, {
|
| 228 |
+
headers: authHeaders(),
|
| 229 |
+
timeoutMs
|
| 230 |
+
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
if (!response.ok) {
|
| 232 |
+
throw new Error(`capabilities 请求失败,状态码 ${response.status}:${text}`);
|
|
|
|
| 233 |
}
|
| 234 |
+
return result;
|
| 235 |
}
|
| 236 |
|
| 237 |
+
async function runGenerateRequest() {
|
| 238 |
+
let lastResult;
|
| 239 |
+
let lastRetryAfter = null;
|
|
|
|
|
|
|
| 240 |
|
| 241 |
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 242 |
+
const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, {
|
| 243 |
+
method: 'POST',
|
| 244 |
+
headers: {
|
| 245 |
+
'Content-Type': 'application/json',
|
| 246 |
+
'Idempotency-Key': idempotencyKey,
|
| 247 |
+
...authHeaders()
|
| 248 |
+
},
|
| 249 |
+
body: JSON.stringify(requestBody),
|
| 250 |
+
timeoutMs
|
| 251 |
+
});
|
| 252 |
+
|
| 253 |
+
if (response.ok) {
|
| 254 |
+
console.log(JSON.stringify(enrichImageUrls(result), null, 2));
|
| 255 |
+
process.exit(0);
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
|
| 259 |
+
lastResult = result;
|
| 260 |
+
lastRetryAfter = retryAfter;
|
| 261 |
+
if (!shouldRetry(result) || attempt === maxAttempts) break;
|
| 262 |
+
await sleep(retryAfter);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
|
| 266 |
+
process.exit(1);
|
| 267 |
}
|
| 268 |
|
| 269 |
+
async function runGenerateJob() {
|
| 270 |
+
let lastResult;
|
| 271 |
+
let lastRetryAfter = null;
|
| 272 |
+
|
| 273 |
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
| 274 |
+
const { response, result } = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, {
|
| 275 |
+
method: 'POST',
|
| 276 |
+
headers: {
|
| 277 |
+
'Content-Type': 'application/json',
|
| 278 |
+
'Idempotency-Key': idempotencyKey,
|
| 279 |
+
...authHeaders()
|
| 280 |
+
},
|
| 281 |
+
body: JSON.stringify(requestBody),
|
| 282 |
+
timeoutMs
|
| 283 |
+
});
|
| 284 |
+
|
| 285 |
+
if (response.ok) {
|
| 286 |
+
const jobResult = await pollJobResult(result?.job);
|
| 287 |
+
console.log(JSON.stringify(enrichImageUrls(jobResult), null, 2));
|
| 288 |
+
process.exit(0);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
const retryAfter = parseRetryAfterValue(response.headers.get('retry-after'));
|
| 292 |
+
lastResult = result;
|
| 293 |
+
lastRetryAfter = retryAfter;
|
| 294 |
+
if (!shouldRetry(result) || attempt === maxAttempts) break;
|
| 295 |
+
await sleep(retryAfter);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
|
| 299 |
+
process.exit(1);
|
| 300 |
}
|
| 301 |
|
| 302 |
+
async function pollJobResult(job) {
|
| 303 |
+
if (!job || typeof job.id !== 'string') {
|
| 304 |
+
throw new Error('创建 job 的响应缺少 job.id。');
|
| 305 |
+
}
|
| 306 |
+
const resultUrl = resolveSameOriginUrl(baseUrl, job.result_url || `/api/agent/jobs/${job.id}/result`, 'job.result_url');
|
| 307 |
+
const deadlineMs = Date.now() + timeoutMs;
|
| 308 |
+
let lastResult;
|
| 309 |
+
let lastRetryAfter = job.retry_after_seconds || 1;
|
| 310 |
+
|
| 311 |
+
while (Date.now() < deadlineMs) {
|
| 312 |
+
const { response, result } = await fetchJson(resultUrl, {
|
| 313 |
+
headers: authHeaders(),
|
| 314 |
+
timeoutMs
|
| 315 |
+
});
|
| 316 |
+
if (response.ok) return result;
|
| 317 |
+
|
| 318 |
+
const retryAfter = parseRetryAfterValue(response.headers.get('retry-after')) || lastRetryAfter;
|
| 319 |
+
lastResult = result;
|
| 320 |
+
lastRetryAfter = retryAfter;
|
| 321 |
+
if (result?.error?.code !== 'request_in_progress' || !result?.error?.retryable) break;
|
| 322 |
+
await sleep(retryAfter);
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
console.error(JSON.stringify({ ...lastResult, retry_after: lastRetryAfter }, null, 2));
|
| 326 |
process.exit(1);
|
| 327 |
}
|
| 328 |
|
| 329 |
+
async function runContractCheck(capabilitiesValue) {
|
| 330 |
+
const checks = [];
|
| 331 |
+
const { response, result } = await fetchJson(`${baseUrl}/api/agent/images/generate`, {
|
| 332 |
method: 'POST',
|
| 333 |
headers: {
|
| 334 |
'Content-Type': 'application/json',
|
| 335 |
...authHeaders()
|
| 336 |
},
|
| 337 |
+
body: JSON.stringify(requestBody),
|
| 338 |
+
timeoutMs
|
|
|
|
|
|
|
|
|
|
| 339 |
});
|
|
|
|
| 340 |
if (response.status === 400 && result?.error?.code === 'idempotency_key_required') {
|
| 341 |
+
checks.push({ endpoint: '/api/agent/images/generate', status: response.status, error_code: result.error.code });
|
| 342 |
+
} else {
|
| 343 |
+
console.error(JSON.stringify({ ok: false, billable: false, status: response.status, result }, null, 2));
|
| 344 |
+
process.exit(1);
|
| 345 |
}
|
| 346 |
+
|
| 347 |
+
if (supportsJobPolling(capabilitiesValue)) {
|
| 348 |
+
const jobCheck = await fetchJson(`${baseUrl}/api/agent/jobs/images/generate`, {
|
| 349 |
+
method: 'POST',
|
| 350 |
+
headers: {
|
| 351 |
+
'Content-Type': 'application/json',
|
| 352 |
+
...authHeaders()
|
| 353 |
+
},
|
| 354 |
+
body: JSON.stringify(requestBody),
|
| 355 |
+
timeoutMs
|
| 356 |
+
});
|
| 357 |
+
if (jobCheck.response.status !== 400 || jobCheck.result?.error?.code !== 'idempotency_key_required') {
|
| 358 |
+
console.error(
|
| 359 |
+
JSON.stringify({ ok: false, billable: false, status: jobCheck.response.status, result: jobCheck.result }, null, 2)
|
| 360 |
+
);
|
| 361 |
+
process.exit(1);
|
| 362 |
+
}
|
| 363 |
+
checks.push({
|
| 364 |
+
endpoint: '/api/agent/jobs/images/generate',
|
| 365 |
+
status: jobCheck.response.status,
|
| 366 |
+
error_code: jobCheck.result.error.code
|
| 367 |
+
});
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
console.log(JSON.stringify({ ok: true, billable: false, checks }, null, 2));
|
| 371 |
}
|
| 372 |
|
| 373 |
+
async function fetchJson(url, init) {
|
| 374 |
+
const controller = new AbortController();
|
| 375 |
+
const timeout = setTimeout(() => controller.abort(), init.timeoutMs);
|
| 376 |
+
try {
|
| 377 |
+
const fetchInit = { ...init };
|
| 378 |
+
delete fetchInit.timeoutMs;
|
| 379 |
+
const response = await fetch(url, { ...fetchInit, signal: controller.signal });
|
| 380 |
+
const text = await response.text();
|
| 381 |
+
const result = parseJsonResponse(text, response.ok, url);
|
| 382 |
+
return { response, result, text };
|
| 383 |
+
} catch (error) {
|
| 384 |
+
const message = errorMessage(error);
|
| 385 |
+
throw new Error(`请求失败:${url}。${message}`);
|
| 386 |
+
} finally {
|
| 387 |
+
clearTimeout(timeout);
|
| 388 |
+
}
|
| 389 |
+
}
|
| 390 |
|
| 391 |
+
function parseJsonResponse(text, isOk, url) {
|
| 392 |
+
if (!text) return null;
|
| 393 |
+
try {
|
| 394 |
+
return JSON.parse(text);
|
| 395 |
+
} catch (error) {
|
| 396 |
+
if (!isOk) return null;
|
| 397 |
+
const message = errorMessage(error);
|
| 398 |
+
throw new Error(`响应不是有效 JSON:${url}。${message}`);
|
| 399 |
+
}
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function shouldRetry(result) {
|
| 403 |
+
return Boolean(result?.error?.retryable);
|
| 404 |
+
}
|
| 405 |
|
| 406 |
+
function supportsJobPolling(capabilitiesValue) {
|
| 407 |
+
return Boolean(capabilitiesValue?.agent_jobs?.supported === true && capabilitiesValue.agent_jobs.mode === 'job_polling');
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
function shouldUseJobPolling(capabilitiesValue, request, jobMode) {
|
| 411 |
+
if (jobMode === 'never') return false;
|
| 412 |
+
if (!supportsJobPolling(capabilitiesValue)) {
|
| 413 |
+
if (jobMode === 'always') {
|
| 414 |
+
throw new Error('服务 capabilities 未声明 agent_jobs.supported=true,不能调用 job endpoint。');
|
| 415 |
+
}
|
| 416 |
+
return false;
|
| 417 |
}
|
| 418 |
+
if (jobMode === 'always') return true;
|
| 419 |
+
return request.quality === 'high' && readMaxImageEdge(request.size) >= 3072;
|
| 420 |
+
}
|
| 421 |
|
| 422 |
+
function readMaxImageEdge(size) {
|
| 423 |
+
if (typeof size !== 'string') return 0;
|
| 424 |
+
const match = size.match(/^(\d+)x(\d+)$/);
|
| 425 |
+
if (!match) return 0;
|
| 426 |
+
return Math.max(Number(match[1]), Number(match[2]));
|
| 427 |
}
|
| 428 |
|
| 429 |
+
function printUsage() {
|
| 430 |
+
console.error('用法:generate-image.mjs [options] <prompt>');
|
| 431 |
+
console.error('默认只输出 dry-run;添加 --allow-billable 才会真实生图。');
|
| 432 |
+
console.error('常用参数:--model --size --quality --n --format --response-mode --timeout-ms --prompt-file --idempotency-key --job --no-job');
|
| 433 |
+
console.error('契约检查:GPT_IMAGE_AGENT_CONTRACT_CHECK=1 generate-image.mjs 或 generate-image.mjs --contract-check');
|
| 434 |
+
}
|
skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const MAX_RETRY_AFTER_SECONDS = 60;
|
| 2 |
+
|
| 3 |
+
export function readOptionValue(argv, index, name) {
|
| 4 |
+
const value = argv[index];
|
| 5 |
+
if (!value || value.startsWith('--')) {
|
| 6 |
+
throw new Error(`${name} 需要参数值。`);
|
| 7 |
+
}
|
| 8 |
+
return value;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function readConfiguredPositiveInteger(value, name, fallback) {
|
| 12 |
+
if (value === undefined || value === null || value === '') return fallback;
|
| 13 |
+
if (!/^\d+$/.test(String(value))) {
|
| 14 |
+
throw new Error(`${name} 必须是正整数。`);
|
| 15 |
+
}
|
| 16 |
+
const parsed = Number(value);
|
| 17 |
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
| 18 |
+
throw new Error(`${name} 必须是正整数。`);
|
| 19 |
+
}
|
| 20 |
+
return parsed;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
export function normalizeBaseUrl(value) {
|
| 24 |
+
const normalized = String(value || '').trim().replace(/\/+$/, '');
|
| 25 |
+
let parsed;
|
| 26 |
+
try {
|
| 27 |
+
parsed = new URL(normalized);
|
| 28 |
+
} catch {
|
| 29 |
+
throw new Error('base URL 必须是有效的 http/https 绝对 URL。');
|
| 30 |
+
}
|
| 31 |
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
| 32 |
+
throw new Error('base URL 必须使用 http 或 https。');
|
| 33 |
+
}
|
| 34 |
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
| 35 |
+
throw new Error('base URL 不能包含凭据、查询参数或片段。');
|
| 36 |
+
}
|
| 37 |
+
return normalized;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export function normalizeOutputFormat(value) {
|
| 41 |
+
return value.toLowerCase() === 'jpg' ? 'jpeg' : value.toLowerCase();
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function parseRetryAfterValue(value, fallback = 1) {
|
| 45 |
+
if (!value || !/^\d+$/.test(value)) return clampRetryAfterSeconds(fallback);
|
| 46 |
+
const parsed = Number(value);
|
| 47 |
+
if (!Number.isSafeInteger(parsed)) return MAX_RETRY_AFTER_SECONDS;
|
| 48 |
+
return clampRetryAfterSeconds(parsed);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function sleep(seconds) {
|
| 52 |
+
return new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function clampRetryAfterSeconds(value) {
|
| 56 |
+
if (!Number.isFinite(value)) return MAX_RETRY_AFTER_SECONDS;
|
| 57 |
+
return Math.min(MAX_RETRY_AFTER_SECONDS, Math.max(1, Math.round(value)));
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
export function resolveSameOriginUrl(baseUrl, value, label) {
|
| 61 |
+
const base = new URL(baseUrl);
|
| 62 |
+
const resolved = new URL(value, `${baseUrl}/`);
|
| 63 |
+
if (resolved.origin !== base.origin) {
|
| 64 |
+
throw new Error(`${label} 指向不同 origin,拒绝携带鉴权头访问。`);
|
| 65 |
+
}
|
| 66 |
+
return resolved.toString();
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export function errorMessage(error) {
|
| 70 |
+
return error instanceof Error ? error.message : String(error);
|
| 71 |
+
}
|
skills/gpt-image-playground-agent/scripts/probe-upstream-image.mjs
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import dns from 'node:dns/promises';
|
| 3 |
+
import tls from 'node:tls';
|
| 4 |
+
import {
|
| 5 |
+
errorMessage,
|
| 6 |
+
normalizeBaseUrl,
|
| 7 |
+
normalizeOutputFormat,
|
| 8 |
+
readConfiguredPositiveInteger,
|
| 9 |
+
readOptionValue
|
| 10 |
+
} from './lib/script-utils.mjs';
|
| 11 |
+
|
| 12 |
+
const HEADER_ALLOWLIST = new Set(['content-type', 'date', 'server', 'cf-ray', 'x-request-id', 'retry-after']);
|
| 13 |
+
let options;
|
| 14 |
+
try {
|
| 15 |
+
options = parseArgs(process.argv.slice(2));
|
| 16 |
+
} catch (error) {
|
| 17 |
+
console.error(errorMessage(error));
|
| 18 |
+
printUsage();
|
| 19 |
+
process.exit(2);
|
| 20 |
+
}
|
| 21 |
+
if (options.help) {
|
| 22 |
+
printUsage();
|
| 23 |
+
process.exit(0);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
let baseUrl;
|
| 27 |
+
try {
|
| 28 |
+
baseUrl = normalizeBaseUrl(
|
| 29 |
+
options.baseUrl || process.env.GPT_IMAGE_UPSTREAM_BASE_URL || process.env.OPENAI_API_BASE_URL || 'https://api.openai.com/v1'
|
| 30 |
+
);
|
| 31 |
+
} catch (error) {
|
| 32 |
+
console.error(errorMessage(error));
|
| 33 |
+
process.exit(2);
|
| 34 |
+
}
|
| 35 |
+
const apiKey = process.env.GPT_IMAGE_UPSTREAM_API_KEY || process.env.OPENAI_API_KEY || '';
|
| 36 |
+
let timeoutMs;
|
| 37 |
+
try {
|
| 38 |
+
timeoutMs = readConfiguredPositiveInteger(options.timeoutMs, '--timeout-ms', 30000);
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error(errorMessage(error));
|
| 41 |
+
printUsage();
|
| 42 |
+
process.exit(2);
|
| 43 |
+
}
|
| 44 |
+
let upstream;
|
| 45 |
+
try {
|
| 46 |
+
upstream = new URL(baseUrl);
|
| 47 |
+
} catch {
|
| 48 |
+
console.error(`无效的上游 base URL:${baseUrl}`);
|
| 49 |
+
process.exit(2);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const report = {
|
| 53 |
+
ok: false,
|
| 54 |
+
billable: false,
|
| 55 |
+
base_url: baseUrl,
|
| 56 |
+
upstream_host: upstream.host,
|
| 57 |
+
api_key_configured: Boolean(apiKey),
|
| 58 |
+
dns: await probeDns(upstream.hostname),
|
| 59 |
+
tls: await probeTls(upstream),
|
| 60 |
+
models: await probeModels()
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
if (options.allowBillable) {
|
| 64 |
+
report.generation = await probeGeneration();
|
| 65 |
+
report.billable = true;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
report.ok = Boolean(report.models.ok && (!report.generation || report.generation.ok));
|
| 69 |
+
console.log(JSON.stringify(report, null, 2));
|
| 70 |
+
process.exit(report.ok ? 0 : 1);
|
| 71 |
+
|
| 72 |
+
function parseArgs(argv) {
|
| 73 |
+
const parsed = {
|
| 74 |
+
baseUrl: undefined,
|
| 75 |
+
model: 'gpt-image-2',
|
| 76 |
+
prompt: 'contract probe',
|
| 77 |
+
size: '1024x1024',
|
| 78 |
+
quality: 'low',
|
| 79 |
+
format: 'png',
|
| 80 |
+
timeoutMs: undefined,
|
| 81 |
+
allowBillable: false,
|
| 82 |
+
help: false
|
| 83 |
+
};
|
| 84 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 85 |
+
const arg = argv[index];
|
| 86 |
+
if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
|
| 87 |
+
else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
|
| 88 |
+
else if (arg === '--prompt') parsed.prompt = readOptionValue(argv, (index += 1), arg);
|
| 89 |
+
else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
|
| 90 |
+
else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
|
| 91 |
+
else if (arg === '--format') parsed.format = readOptionValue(argv, (index += 1), arg);
|
| 92 |
+
else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
|
| 93 |
+
else if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 94 |
+
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 95 |
+
else throw new Error(`未知参数:${arg}`);
|
| 96 |
+
}
|
| 97 |
+
return parsed;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
async function probeDns(hostname) {
|
| 101 |
+
const startedAt = Date.now();
|
| 102 |
+
try {
|
| 103 |
+
const result = await dns.lookup(hostname);
|
| 104 |
+
return { ok: true, elapsed_ms: Date.now() - startedAt, address_family: result.family };
|
| 105 |
+
} catch (error) {
|
| 106 |
+
return { ok: false, elapsed_ms: Date.now() - startedAt, error: errorMessage(error) };
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
async function probeTls(url) {
|
| 111 |
+
if (url.protocol !== 'https:') {
|
| 112 |
+
return { ok: true, skipped: true, reason: 'non_https_base_url' };
|
| 113 |
+
}
|
| 114 |
+
const startedAt = Date.now();
|
| 115 |
+
return new Promise((resolve) => {
|
| 116 |
+
const socket = tls.connect({
|
| 117 |
+
host: url.hostname,
|
| 118 |
+
port: Number(url.port || 443),
|
| 119 |
+
servername: url.hostname,
|
| 120 |
+
timeout: timeoutMs
|
| 121 |
+
});
|
| 122 |
+
socket.once('secureConnect', () => {
|
| 123 |
+
const protocol = socket.getProtocol() || undefined;
|
| 124 |
+
socket.end();
|
| 125 |
+
resolve({ ok: true, elapsed_ms: Date.now() - startedAt, authorized: socket.authorized, protocol });
|
| 126 |
+
});
|
| 127 |
+
socket.once('timeout', () => {
|
| 128 |
+
socket.destroy();
|
| 129 |
+
resolve({ ok: false, elapsed_ms: Date.now() - startedAt, error: 'TLS handshake timed out.' });
|
| 130 |
+
});
|
| 131 |
+
socket.once('error', (error) => {
|
| 132 |
+
resolve({ ok: false, elapsed_ms: Date.now() - startedAt, error: errorMessage(error) });
|
| 133 |
+
});
|
| 134 |
+
});
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
async function probeModels() {
|
| 138 |
+
const { response, json, text, elapsedMs } = await fetchJson(`${baseUrl}/models`, { method: 'GET' });
|
| 139 |
+
return {
|
| 140 |
+
ok: response.ok,
|
| 141 |
+
status: response.status,
|
| 142 |
+
elapsed_ms: elapsedMs,
|
| 143 |
+
content_type: response.headers.get('content-type') || undefined,
|
| 144 |
+
response_headers: readAllowedHeaders(response.headers),
|
| 145 |
+
...summarizeJson(json, text)
|
| 146 |
+
};
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
async function probeGeneration() {
|
| 150 |
+
const { response, json, text, elapsedMs } = await fetchJson(`${baseUrl}/images/generations`, {
|
| 151 |
+
method: 'POST',
|
| 152 |
+
headers: { 'Content-Type': 'application/json' },
|
| 153 |
+
body: JSON.stringify({
|
| 154 |
+
model: options.model,
|
| 155 |
+
prompt: options.prompt,
|
| 156 |
+
n: 1,
|
| 157 |
+
size: options.size,
|
| 158 |
+
quality: options.quality,
|
| 159 |
+
output_format: normalizeOutputFormat(options.format)
|
| 160 |
+
})
|
| 161 |
+
});
|
| 162 |
+
return {
|
| 163 |
+
ok: response.ok,
|
| 164 |
+
billable: true,
|
| 165 |
+
status: response.status,
|
| 166 |
+
elapsed_ms: elapsedMs,
|
| 167 |
+
content_type: response.headers.get('content-type') || undefined,
|
| 168 |
+
response_headers: readAllowedHeaders(response.headers),
|
| 169 |
+
...summarizeGenerationJson(json, text)
|
| 170 |
+
};
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
async function fetchJson(url, init) {
|
| 174 |
+
const controller = new AbortController();
|
| 175 |
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
| 176 |
+
const startedAt = Date.now();
|
| 177 |
+
try {
|
| 178 |
+
const response = await fetch(url, {
|
| 179 |
+
...init,
|
| 180 |
+
headers: {
|
| 181 |
+
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
|
| 182 |
+
...(init.headers || {})
|
| 183 |
+
},
|
| 184 |
+
signal: controller.signal
|
| 185 |
+
});
|
| 186 |
+
const text = await response.text();
|
| 187 |
+
return { response, text, json: parseJson(text), elapsedMs: Date.now() - startedAt };
|
| 188 |
+
} catch (error) {
|
| 189 |
+
return {
|
| 190 |
+
response: new Response(JSON.stringify({ error: errorMessage(error) }), { status: 599 }),
|
| 191 |
+
text: '',
|
| 192 |
+
json: { error: errorMessage(error) },
|
| 193 |
+
elapsedMs: Date.now() - startedAt
|
| 194 |
+
};
|
| 195 |
+
} finally {
|
| 196 |
+
clearTimeout(timeout);
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
function summarizeJson(json, text) {
|
| 201 |
+
if (Array.isArray(json?.data)) {
|
| 202 |
+
return { model_count: json.data.length };
|
| 203 |
+
}
|
| 204 |
+
return { error: summarizeError(json, text) };
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function summarizeGenerationJson(json, text) {
|
| 208 |
+
if (!Array.isArray(json?.data)) {
|
| 209 |
+
return { error: summarizeError(json, text) };
|
| 210 |
+
}
|
| 211 |
+
const firstImage = json.data[0] || {};
|
| 212 |
+
const firstB64 = typeof firstImage.b64_json === 'string' ? firstImage.b64_json : '';
|
| 213 |
+
return {
|
| 214 |
+
image_count: json.data.length,
|
| 215 |
+
first_b64_length: firstB64.length,
|
| 216 |
+
usage: json.usage || undefined
|
| 217 |
+
};
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function summarizeError(json, text) {
|
| 221 |
+
const error = json?.error;
|
| 222 |
+
if (typeof error === 'object' && error) {
|
| 223 |
+
return {
|
| 224 |
+
code: typeof error.code === 'string' ? error.code : undefined,
|
| 225 |
+
type: typeof error.type === 'string' ? error.type : undefined,
|
| 226 |
+
message: typeof error.message === 'string' ? error.message : undefined
|
| 227 |
+
};
|
| 228 |
+
}
|
| 229 |
+
return text ? { message: text.slice(0, 500) } : undefined;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function readAllowedHeaders(headers) {
|
| 233 |
+
const result = {};
|
| 234 |
+
for (const name of HEADER_ALLOWLIST) {
|
| 235 |
+
const value = headers.get(name);
|
| 236 |
+
if (value) result[name] = value;
|
| 237 |
+
}
|
| 238 |
+
return Object.keys(result).length > 0 ? result : undefined;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
function parseJson(text) {
|
| 242 |
+
try {
|
| 243 |
+
return text ? JSON.parse(text) : null;
|
| 244 |
+
} catch {
|
| 245 |
+
return null;
|
| 246 |
+
}
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function printUsage() {
|
| 250 |
+
console.error('用法:probe-upstream-image.mjs [options]');
|
| 251 |
+
console.error('默认只请求 /models;添加 --allow-billable 才会调用 /images/generations。');
|
| 252 |
+
console.error('常用参数:--base-url --model --prompt --size --quality --format --timeout-ms --allow-billable');
|
| 253 |
+
}
|
src/app/api/agent/agent-routes.test.ts
CHANGED
|
@@ -56,14 +56,15 @@ describe('Agent route integration', () => {
|
|
| 56 |
const { getCapabilities } = await loadAgentRoutes();
|
| 57 |
process.env.AGENT_STATE_BACKEND = 'memory';
|
| 58 |
process.env.AGENT_API_TOKEN = 'capability-token';
|
| 59 |
-
process.env.APP_PASSWORD = 'page-
|
| 60 |
|
| 61 |
const response = await getCapabilities();
|
| 62 |
assert.equal(response.status, 200);
|
| 63 |
const body = await response.json();
|
| 64 |
assert.equal(body.auth.required, true);
|
|
|
|
| 65 |
assert.equal(JSON.stringify(body).includes('capability-token'), false);
|
| 66 |
-
assert.equal(JSON.stringify(body).includes('page-
|
| 67 |
assert.equal(body.defaults.state_backend, 'memory');
|
| 68 |
});
|
| 69 |
|
|
@@ -76,6 +77,7 @@ describe('Agent route integration', () => {
|
|
| 76 |
assert.equal(response.status, 200);
|
| 77 |
const body = await response.json();
|
| 78 |
assert.equal(body.auth.required, false);
|
|
|
|
| 79 |
});
|
| 80 |
|
| 81 |
it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
|
|
@@ -104,6 +106,7 @@ describe('Agent route integration', () => {
|
|
| 104 |
const secondBody = await second.json();
|
| 105 |
assert.equal(secondBody.cached, true);
|
| 106 |
assert.equal(second.headers.get('x-idempotent-replay'), 'true');
|
|
|
|
| 107 |
assert.equal(secondBody.request_id, firstBody.request_id);
|
| 108 |
assert.equal(upstreamCalls, 1);
|
| 109 |
|
|
@@ -220,6 +223,297 @@ describe('Agent route integration', () => {
|
|
| 220 |
assert.equal(body.error.retryable, false);
|
| 221 |
});
|
| 222 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
it('edits through multipart input and replays the cached response for the same idempotency key', async () => {
|
| 224 |
const { editImage } = await loadAgentRoutes();
|
| 225 |
let upstreamCalls = 0;
|
|
@@ -242,6 +536,7 @@ describe('Agent route integration', () => {
|
|
| 242 |
const secondBody = await second.json();
|
| 243 |
assert.equal(secondBody.cached, true);
|
| 244 |
assert.equal(second.headers.get('x-idempotent-replay'), 'true');
|
|
|
|
| 245 |
assert.equal(secondBody.request_id, firstBody.request_id);
|
| 246 |
assert.equal(upstreamCalls, 1);
|
| 247 |
|
|
@@ -304,6 +599,126 @@ describe('Agent route integration', () => {
|
|
| 304 |
assert.equal(body.error.retryable, false);
|
| 305 |
});
|
| 306 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
it('requires artifact content authorization and returns image bytes when authorized', async () => {
|
| 308 |
const { generateImage, getArtifact, getArtifactContent, deleteArtifact } = await loadAgentRoutes();
|
| 309 |
const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
|
@@ -424,6 +839,9 @@ describe('Agent route integration', () => {
|
|
| 424 |
}
|
| 425 |
};
|
| 426 |
},
|
|
|
|
|
|
|
|
|
|
| 427 |
async saveArtifacts() {
|
| 428 |
saveCalls += 1;
|
| 429 |
},
|
|
@@ -433,6 +851,9 @@ describe('Agent route integration', () => {
|
|
| 433 |
async failRequest() {
|
| 434 |
failCalls += 1;
|
| 435 |
},
|
|
|
|
|
|
|
|
|
|
| 436 |
async getArtifact() {
|
| 437 |
return undefined;
|
| 438 |
},
|
|
@@ -495,6 +916,9 @@ describe('Agent route integration', () => {
|
|
| 495 |
}
|
| 496 |
};
|
| 497 |
},
|
|
|
|
|
|
|
|
|
|
| 498 |
async saveArtifacts() {
|
| 499 |
throw new Error('artifact metadata save failed');
|
| 500 |
},
|
|
@@ -503,6 +927,9 @@ describe('Agent route integration', () => {
|
|
| 503 |
assert.equal(input.requestId, requestId);
|
| 504 |
assert.equal(input.error.error.retryable, true);
|
| 505 |
},
|
|
|
|
|
|
|
|
|
|
| 506 |
async getArtifact() {
|
| 507 |
return undefined;
|
| 508 |
},
|
|
@@ -596,10 +1023,16 @@ async function loadAgentRoutes() {
|
|
| 596 |
const artifactRoute = await import('./artifacts/[id]/route');
|
| 597 |
const artifactContentRoute = await import('./artifacts/[id]/content/route');
|
| 598 |
const capabilitiesRoute = await import('./capabilities/route');
|
|
|
|
|
|
|
|
|
|
| 599 |
return {
|
| 600 |
getCapabilities: capabilitiesRoute.GET,
|
| 601 |
generateImage: generateRoute.POST,
|
| 602 |
editImage: editRoute.POST,
|
|
|
|
|
|
|
|
|
|
| 603 |
getArtifact: artifactRoute.GET,
|
| 604 |
deleteArtifact: artifactRoute.DELETE,
|
| 605 |
getArtifactContent: artifactContentRoute.GET
|
|
@@ -618,6 +1051,18 @@ function agentJsonRequest(idempotencyKey: string, body: Record<string, unknown>,
|
|
| 618 |
});
|
| 619 |
}
|
| 620 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
function agentEditRequest(
|
| 622 |
idempotencyKey: string,
|
| 623 |
prompt: string,
|
|
@@ -716,3 +1161,22 @@ async function waitFor(predicate: () => boolean): Promise<void> {
|
|
| 716 |
}
|
| 717 |
throw new Error('等待条件超时');
|
| 718 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
const { getCapabilities } = await loadAgentRoutes();
|
| 57 |
process.env.AGENT_STATE_BACKEND = 'memory';
|
| 58 |
process.env.AGENT_API_TOKEN = 'capability-token';
|
| 59 |
+
process.env.APP_PASSWORD = 'page-access-code';
|
| 60 |
|
| 61 |
const response = await getCapabilities();
|
| 62 |
assert.equal(response.status, 200);
|
| 63 |
const body = await response.json();
|
| 64 |
assert.equal(body.auth.required, true);
|
| 65 |
+
assert.deepEqual(body.auth.schemes, ['bearer']);
|
| 66 |
assert.equal(JSON.stringify(body).includes('capability-token'), false);
|
| 67 |
+
assert.equal(JSON.stringify(body).includes('page-access-code'), false);
|
| 68 |
assert.equal(body.defaults.state_backend, 'memory');
|
| 69 |
});
|
| 70 |
|
|
|
|
| 77 |
assert.equal(response.status, 200);
|
| 78 |
const body = await response.json();
|
| 79 |
assert.equal(body.auth.required, false);
|
| 80 |
+
assert.deepEqual(body.auth.schemes, []);
|
| 81 |
});
|
| 82 |
|
| 83 |
it('generates through a compatible upstream once and replays the cached response for the same idempotency key', async () => {
|
|
|
|
| 106 |
const secondBody = await second.json();
|
| 107 |
assert.equal(secondBody.cached, true);
|
| 108 |
assert.equal(second.headers.get('x-idempotent-replay'), 'true');
|
| 109 |
+
assert.equal(second.headers.get('x-request-id'), firstBody.request_id);
|
| 110 |
assert.equal(secondBody.request_id, firstBody.request_id);
|
| 111 |
assert.equal(upstreamCalls, 1);
|
| 112 |
|
|
|
|
| 223 |
assert.equal(body.error.retryable, false);
|
| 224 |
});
|
| 225 |
|
| 226 |
+
it('returns sanitized upstream diagnostics for failed generate requests', async () => {
|
| 227 |
+
const { generateImage } = await loadAgentRoutes();
|
| 228 |
+
let upstreamCalls = 0;
|
| 229 |
+
const upstream = await startImageUpstream(() => {
|
| 230 |
+
upstreamCalls += 1;
|
| 231 |
+
throw new Error('upstream failed');
|
| 232 |
+
});
|
| 233 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 234 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 235 |
+
|
| 236 |
+
try {
|
| 237 |
+
const idempotencyKey = 'route-upstream-diagnostics-key';
|
| 238 |
+
const response = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' }));
|
| 239 |
+
assert.equal(response.status, 502);
|
| 240 |
+
const body = await response.json();
|
| 241 |
+
assert.equal(body.error.code, 'upstream_unavailable');
|
| 242 |
+
assert.equal(body.error.upstream_status, 500);
|
| 243 |
+
assert.equal(body.error.diagnostics.upstream_status, 500);
|
| 244 |
+
assert.equal(body.error.diagnostics.selected_channel_id, 'default');
|
| 245 |
+
assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
|
| 246 |
+
assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel');
|
| 247 |
+
assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number');
|
| 248 |
+
assert.equal(JSON.stringify(body).includes('test-key'), false);
|
| 249 |
+
const upstreamCallsAfterFirstFailure = upstreamCalls;
|
| 250 |
+
|
| 251 |
+
const replay = await generateImage(agentJsonRequest(idempotencyKey, { prompt: 'diagnostics' }));
|
| 252 |
+
assert.equal(replay.status, 502);
|
| 253 |
+
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
| 254 |
+
const replayBody = await replay.json();
|
| 255 |
+
assert.equal(replayBody.error.code, 'upstream_unavailable');
|
| 256 |
+
assert.equal(replayBody.error.retryable, false);
|
| 257 |
+
assert.equal(replayBody.error.request_id, body.error.request_id);
|
| 258 |
+
assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure);
|
| 259 |
+
} finally {
|
| 260 |
+
await upstream.close();
|
| 261 |
+
}
|
| 262 |
+
});
|
| 263 |
+
|
| 264 |
+
it('creates a generate job, exposes running status, and returns the completed result', async () => {
|
| 265 |
+
const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
|
| 266 |
+
let releaseUpstream: (() => void) | undefined;
|
| 267 |
+
let upstreamCalls = 0;
|
| 268 |
+
const upstream = await startImageUpstream(async () => {
|
| 269 |
+
upstreamCalls += 1;
|
| 270 |
+
await new Promise<void>((resolve) => {
|
| 271 |
+
releaseUpstream = resolve;
|
| 272 |
+
});
|
| 273 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 274 |
+
});
|
| 275 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 276 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 277 |
+
|
| 278 |
+
try {
|
| 279 |
+
const created = await createGenerateJob(agentJobJsonRequest('route-job-key', { prompt: 'agent route job' }));
|
| 280 |
+
assert.equal(created.status, 202);
|
| 281 |
+
const createdBody = await created.json();
|
| 282 |
+
assert.equal(createdBody.job.state, 'running');
|
| 283 |
+
assert.equal(createdBody.job.idempotency_key, 'route-job-key');
|
| 284 |
+
assert.equal(createdBody.job.result_url, `/api/agent/jobs/${createdBody.job.id}/result`);
|
| 285 |
+
|
| 286 |
+
await waitFor(() => upstreamCalls === 1);
|
| 287 |
+
const running = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
|
| 288 |
+
params: Promise.resolve({ id: createdBody.job.id })
|
| 289 |
+
});
|
| 290 |
+
assert.equal(running.status, 200);
|
| 291 |
+
assert.equal((await running.json()).job.state, 'running');
|
| 292 |
+
|
| 293 |
+
releaseUpstream?.();
|
| 294 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 295 |
+
assert.equal(result.status, 200);
|
| 296 |
+
const resultBody = await result.json();
|
| 297 |
+
assert.equal(resultBody.request_id, createdBody.job.id);
|
| 298 |
+
assert.equal(resultBody.cached, false);
|
| 299 |
+
assert.equal(resultBody.images.length, 1);
|
| 300 |
+
assert.equal('b64_json' in resultBody.images[0], false);
|
| 301 |
+
} finally {
|
| 302 |
+
releaseUpstream?.();
|
| 303 |
+
await upstream.close();
|
| 304 |
+
}
|
| 305 |
+
});
|
| 306 |
+
|
| 307 |
+
it('reuses the running generate job for the same idempotency key', async () => {
|
| 308 |
+
const { createGenerateJob, getJobResult } = await loadAgentRoutes();
|
| 309 |
+
let releaseUpstream: (() => void) | undefined;
|
| 310 |
+
let upstreamCalls = 0;
|
| 311 |
+
const upstream = await startImageUpstream(async () => {
|
| 312 |
+
upstreamCalls += 1;
|
| 313 |
+
await new Promise<void>((resolve) => {
|
| 314 |
+
releaseUpstream = resolve;
|
| 315 |
+
});
|
| 316 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 317 |
+
});
|
| 318 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 319 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 320 |
+
|
| 321 |
+
try {
|
| 322 |
+
const first = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' }));
|
| 323 |
+
assert.equal(first.status, 202);
|
| 324 |
+
const firstBody = await first.json();
|
| 325 |
+
await waitFor(() => upstreamCalls === 1);
|
| 326 |
+
|
| 327 |
+
const second = await createGenerateJob(agentJobJsonRequest('route-job-reuse-key', { prompt: 'job reuse' }));
|
| 328 |
+
assert.equal(second.status, 202);
|
| 329 |
+
assert.equal(second.headers.get('x-idempotent-replay'), 'true');
|
| 330 |
+
const secondBody = await second.json();
|
| 331 |
+
assert.equal(secondBody.job.id, firstBody.job.id);
|
| 332 |
+
assert.equal(secondBody.job.state, 'running');
|
| 333 |
+
assert.equal(upstreamCalls, 1);
|
| 334 |
+
|
| 335 |
+
releaseUpstream?.();
|
| 336 |
+
const result = await waitForJobResult(getJobResult, firstBody.job.id);
|
| 337 |
+
assert.equal(result.status, 200);
|
| 338 |
+
} finally {
|
| 339 |
+
releaseUpstream?.();
|
| 340 |
+
await upstream.close();
|
| 341 |
+
}
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
+
it('rejects generate job idempotency keys reused with a different request body', async () => {
|
| 345 |
+
const { createGenerateJob, getJobResult } = await loadAgentRoutes();
|
| 346 |
+
let releaseUpstream: (() => void) | undefined;
|
| 347 |
+
let upstreamCalls = 0;
|
| 348 |
+
const upstream = await startImageUpstream(async () => {
|
| 349 |
+
upstreamCalls += 1;
|
| 350 |
+
await new Promise<void>((resolve) => {
|
| 351 |
+
releaseUpstream = resolve;
|
| 352 |
+
});
|
| 353 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 354 |
+
});
|
| 355 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 356 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 357 |
+
|
| 358 |
+
try {
|
| 359 |
+
const first = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'first job body' }));
|
| 360 |
+
assert.equal(first.status, 202);
|
| 361 |
+
const firstBody = await first.json();
|
| 362 |
+
await waitFor(() => upstreamCalls === 1);
|
| 363 |
+
|
| 364 |
+
const conflict = await createGenerateJob(agentJobJsonRequest('route-job-conflict-key', { prompt: 'different job body' }));
|
| 365 |
+
assert.equal(conflict.status, 409);
|
| 366 |
+
assert.equal((await conflict.json()).error.code, 'idempotency_conflict');
|
| 367 |
+
assert.equal(upstreamCalls, 1);
|
| 368 |
+
|
| 369 |
+
releaseUpstream?.();
|
| 370 |
+
const result = await waitForJobResult(getJobResult, firstBody.job.id);
|
| 371 |
+
assert.equal(result.status, 200);
|
| 372 |
+
} finally {
|
| 373 |
+
releaseUpstream?.();
|
| 374 |
+
await upstream.close();
|
| 375 |
+
}
|
| 376 |
+
});
|
| 377 |
+
|
| 378 |
+
it('returns stored Agent errors for failed generate jobs', async () => {
|
| 379 |
+
const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
|
| 380 |
+
let upstreamCalls = 0;
|
| 381 |
+
const upstream = await startImageUpstream(() => {
|
| 382 |
+
upstreamCalls += 1;
|
| 383 |
+
throw new Error('job upstream failed');
|
| 384 |
+
});
|
| 385 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 386 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 387 |
+
|
| 388 |
+
try {
|
| 389 |
+
const created = await createGenerateJob(agentJobJsonRequest('route-job-failure-key', { prompt: 'job failure' }));
|
| 390 |
+
assert.equal(created.status, 202);
|
| 391 |
+
const createdBody = await created.json();
|
| 392 |
+
|
| 393 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 394 |
+
assert.equal(result.status, 502);
|
| 395 |
+
const resultBody = await result.json();
|
| 396 |
+
assert.equal(resultBody.error.code, 'upstream_unavailable');
|
| 397 |
+
assert.equal(resultBody.error.retryable, false);
|
| 398 |
+
assert.equal(resultBody.error.upstream_status, 500);
|
| 399 |
+
assert.equal(resultBody.error.diagnostics.upstream_status, 500);
|
| 400 |
+
assert.equal(resultBody.error.request_id, createdBody.job.id);
|
| 401 |
+
assert.equal(JSON.stringify(resultBody).includes('test-key'), false);
|
| 402 |
+
|
| 403 |
+
const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
|
| 404 |
+
params: Promise.resolve({ id: createdBody.job.id })
|
| 405 |
+
});
|
| 406 |
+
assert.equal(status.status, 200);
|
| 407 |
+
const statusBody = await status.json();
|
| 408 |
+
assert.equal(statusBody.job.state, 'failed');
|
| 409 |
+
assert.equal(statusBody.job.error.code, 'upstream_unavailable');
|
| 410 |
+
assert.equal(statusBody.job.error.retryable, false);
|
| 411 |
+
assert.equal(statusBody.job.error.upstream_status, 500);
|
| 412 |
+
assert.equal(statusBody.job.error.diagnostics.upstream_status, 500);
|
| 413 |
+
assert.equal(upstreamCalls > 0, true);
|
| 414 |
+
} finally {
|
| 415 |
+
await upstream.close();
|
| 416 |
+
}
|
| 417 |
+
});
|
| 418 |
+
|
| 419 |
+
it('keeps a long-running generate job leased while the upstream call is still active', async () => {
|
| 420 |
+
process.env.AGENT_REQUEST_LEASE_MS = '200';
|
| 421 |
+
process.env.AGENT_RECOVERY_INTERVAL_MS = '50';
|
| 422 |
+
const { createGenerateJob, getJob, getJobResult } = await loadAgentRoutes();
|
| 423 |
+
let releaseUpstream: (() => void) | undefined;
|
| 424 |
+
let upstreamCalls = 0;
|
| 425 |
+
const upstream = await startImageUpstream(async () => {
|
| 426 |
+
upstreamCalls += 1;
|
| 427 |
+
await new Promise<void>((resolve) => {
|
| 428 |
+
releaseUpstream = resolve;
|
| 429 |
+
});
|
| 430 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 431 |
+
});
|
| 432 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 433 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 434 |
+
|
| 435 |
+
try {
|
| 436 |
+
const created = await createGenerateJob(agentJobJsonRequest('route-job-lease-key', { prompt: 'job lease' }));
|
| 437 |
+
assert.equal(created.status, 202);
|
| 438 |
+
const createdBody = await created.json();
|
| 439 |
+
await waitFor(() => upstreamCalls === 1);
|
| 440 |
+
await new Promise((resolve) => setTimeout(resolve, 350));
|
| 441 |
+
|
| 442 |
+
const status = await getJob(new Request(`http://localhost/api/agent/jobs/${createdBody.job.id}`), {
|
| 443 |
+
params: Promise.resolve({ id: createdBody.job.id })
|
| 444 |
+
});
|
| 445 |
+
assert.equal(status.status, 200);
|
| 446 |
+
const statusBody = await status.json();
|
| 447 |
+
assert.equal(statusBody.job.state, 'running');
|
| 448 |
+
|
| 449 |
+
releaseUpstream?.();
|
| 450 |
+
const result = await waitForJobResult(getJobResult, createdBody.job.id);
|
| 451 |
+
assert.equal(result.status, 200);
|
| 452 |
+
} finally {
|
| 453 |
+
releaseUpstream?.();
|
| 454 |
+
await upstream.close();
|
| 455 |
+
}
|
| 456 |
+
});
|
| 457 |
+
|
| 458 |
+
it('returns structured errors for missing and expired jobs', async () => {
|
| 459 |
+
const { getJob, getJobResult } = await loadAgentRoutes();
|
| 460 |
+
const { resetAgentStateStoreForTests, setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime');
|
| 461 |
+
|
| 462 |
+
const missing = await getJob(new Request('http://localhost/api/agent/jobs/missing-job'), {
|
| 463 |
+
params: Promise.resolve({ id: 'missing-job' })
|
| 464 |
+
});
|
| 465 |
+
assert.equal(missing.status, 404);
|
| 466 |
+
assert.equal((await missing.json()).error.code, 'job_not_found');
|
| 467 |
+
|
| 468 |
+
setAgentStateStoreFactoryForTests(() => ({
|
| 469 |
+
async init() {},
|
| 470 |
+
async recoverExpiredRequests() {
|
| 471 |
+
return 0;
|
| 472 |
+
},
|
| 473 |
+
async purgeExpiredRequests() {
|
| 474 |
+
return 0;
|
| 475 |
+
},
|
| 476 |
+
async beginRequest() {
|
| 477 |
+
throw new Error('not used');
|
| 478 |
+
},
|
| 479 |
+
async refreshRequestLease() {
|
| 480 |
+
return false;
|
| 481 |
+
},
|
| 482 |
+
async saveArtifacts() {},
|
| 483 |
+
async completeRequest() {},
|
| 484 |
+
async failRequest() {},
|
| 485 |
+
async getRequest() {
|
| 486 |
+
return {
|
| 487 |
+
requestId: 'expired-job',
|
| 488 |
+
idempotencyKey: 'expired-key',
|
| 489 |
+
requestHash: 'hash',
|
| 490 |
+
mode: 'generate',
|
| 491 |
+
status: 'running',
|
| 492 |
+
requestJson: { prompt: 'expired' },
|
| 493 |
+
createdAt: '2026-05-12T00:00:00.000Z',
|
| 494 |
+
updatedAt: '2026-05-12T00:00:00.000Z',
|
| 495 |
+
expiresAt: '2026-05-12T00:00:01.000Z'
|
| 496 |
+
};
|
| 497 |
+
},
|
| 498 |
+
async getArtifact() {
|
| 499 |
+
return undefined;
|
| 500 |
+
},
|
| 501 |
+
async listArtifactsForRequest() {
|
| 502 |
+
return [];
|
| 503 |
+
},
|
| 504 |
+
async deleteArtifact() {
|
| 505 |
+
return false;
|
| 506 |
+
}
|
| 507 |
+
}));
|
| 508 |
+
resetAgentStateStoreForTests();
|
| 509 |
+
|
| 510 |
+
const expired = await getJobResult(new Request('http://localhost/api/agent/jobs/expired-job/result'), {
|
| 511 |
+
params: Promise.resolve({ id: 'expired-job' })
|
| 512 |
+
});
|
| 513 |
+
assert.equal(expired.status, 410);
|
| 514 |
+
assert.equal((await expired.json()).error.code, 'job_expired');
|
| 515 |
+
});
|
| 516 |
+
|
| 517 |
it('edits through multipart input and replays the cached response for the same idempotency key', async () => {
|
| 518 |
const { editImage } = await loadAgentRoutes();
|
| 519 |
let upstreamCalls = 0;
|
|
|
|
| 536 |
const secondBody = await second.json();
|
| 537 |
assert.equal(secondBody.cached, true);
|
| 538 |
assert.equal(second.headers.get('x-idempotent-replay'), 'true');
|
| 539 |
+
assert.equal(second.headers.get('x-request-id'), firstBody.request_id);
|
| 540 |
assert.equal(secondBody.request_id, firstBody.request_id);
|
| 541 |
assert.equal(upstreamCalls, 1);
|
| 542 |
|
|
|
|
| 599 |
assert.equal(body.error.retryable, false);
|
| 600 |
});
|
| 601 |
|
| 602 |
+
it('returns sanitized upstream diagnostics for failed edit requests', async () => {
|
| 603 |
+
const { editImage } = await loadAgentRoutes();
|
| 604 |
+
let upstreamCalls = 0;
|
| 605 |
+
const upstream = await startImageUpstream(() => {
|
| 606 |
+
upstreamCalls += 1;
|
| 607 |
+
throw new Error('edit upstream failed');
|
| 608 |
+
});
|
| 609 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 610 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 611 |
+
|
| 612 |
+
try {
|
| 613 |
+
const idempotencyKey = 'route-edit-upstream-diagnostics-key';
|
| 614 |
+
const response = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics'));
|
| 615 |
+
assert.equal(response.status, 502);
|
| 616 |
+
const body = await response.json();
|
| 617 |
+
assert.equal(body.error.code, 'upstream_unavailable');
|
| 618 |
+
assert.equal(body.error.upstream_status, 500);
|
| 619 |
+
assert.equal(body.error.diagnostics.upstream_status, 500);
|
| 620 |
+
assert.equal(body.error.diagnostics.selected_channel_id, 'default');
|
| 621 |
+
assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
|
| 622 |
+
assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel');
|
| 623 |
+
assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number');
|
| 624 |
+
assert.equal(JSON.stringify(body).includes('test-key'), false);
|
| 625 |
+
const upstreamCallsAfterFirstFailure = upstreamCalls;
|
| 626 |
+
|
| 627 |
+
const replay = await editImage(agentEditRequest(idempotencyKey, 'edit diagnostics'));
|
| 628 |
+
assert.equal(replay.status, 502);
|
| 629 |
+
assert.equal(replay.headers.get('x-idempotent-replay'), 'true');
|
| 630 |
+
const replayBody = await replay.json();
|
| 631 |
+
assert.equal(replayBody.error.code, 'upstream_unavailable');
|
| 632 |
+
assert.equal(replayBody.error.retryable, false);
|
| 633 |
+
assert.equal(replayBody.error.request_id, body.error.request_id);
|
| 634 |
+
assert.equal(upstreamCalls, upstreamCallsAfterFirstFailure);
|
| 635 |
+
} finally {
|
| 636 |
+
await upstream.close();
|
| 637 |
+
}
|
| 638 |
+
});
|
| 639 |
+
|
| 640 |
+
it('does not mark a real upstream success as failed when edit state completion fails', async () => {
|
| 641 |
+
const { editImage } = await loadAgentRoutes();
|
| 642 |
+
const { setAgentStateStoreFactoryForTests } = await import('@/lib/agent-state-runtime');
|
| 643 |
+
let upstreamCalls = 0;
|
| 644 |
+
let failCalls = 0;
|
| 645 |
+
let saveCalls = 0;
|
| 646 |
+
const requestId = 'edit-completion-failure-request';
|
| 647 |
+
const upstream = await startImageUpstream(() => {
|
| 648 |
+
upstreamCalls += 1;
|
| 649 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 650 |
+
});
|
| 651 |
+
process.env.OPENAI_API_KEY = 'test-key';
|
| 652 |
+
process.env.OPENAI_API_BASE_URL = upstream.baseUrl;
|
| 653 |
+
setAgentStateStoreFactoryForTests(() => ({
|
| 654 |
+
async init() {},
|
| 655 |
+
async recoverExpiredRequests() {
|
| 656 |
+
return 0;
|
| 657 |
+
},
|
| 658 |
+
async purgeExpiredRequests() {
|
| 659 |
+
return 0;
|
| 660 |
+
},
|
| 661 |
+
async beginRequest() {
|
| 662 |
+
return {
|
| 663 |
+
type: 'acquired',
|
| 664 |
+
record: {
|
| 665 |
+
requestId,
|
| 666 |
+
idempotencyKey: 'edit-completion-failure-key',
|
| 667 |
+
requestHash: 'hash',
|
| 668 |
+
mode: 'edit',
|
| 669 |
+
status: 'running',
|
| 670 |
+
requestJson: { fields: { prompt: 'state completion failure' } },
|
| 671 |
+
createdAt: '2026-05-12T00:00:00.000Z',
|
| 672 |
+
updatedAt: '2026-05-12T00:00:00.000Z',
|
| 673 |
+
expiresAt: '2026-05-13T00:00:00.000Z'
|
| 674 |
+
}
|
| 675 |
+
};
|
| 676 |
+
},
|
| 677 |
+
async refreshRequestLease() {
|
| 678 |
+
return false;
|
| 679 |
+
},
|
| 680 |
+
async saveArtifacts() {
|
| 681 |
+
saveCalls += 1;
|
| 682 |
+
},
|
| 683 |
+
async completeRequest() {
|
| 684 |
+
throw new Error('state completion failed');
|
| 685 |
+
},
|
| 686 |
+
async failRequest() {
|
| 687 |
+
failCalls += 1;
|
| 688 |
+
},
|
| 689 |
+
async getRequest() {
|
| 690 |
+
return undefined;
|
| 691 |
+
},
|
| 692 |
+
async getArtifact() {
|
| 693 |
+
return undefined;
|
| 694 |
+
},
|
| 695 |
+
async listArtifactsForRequest() {
|
| 696 |
+
return [];
|
| 697 |
+
},
|
| 698 |
+
async deleteArtifact() {
|
| 699 |
+
return false;
|
| 700 |
+
}
|
| 701 |
+
}));
|
| 702 |
+
|
| 703 |
+
const originalConsoleError = console.error;
|
| 704 |
+
console.error = () => {};
|
| 705 |
+
try {
|
| 706 |
+
const response = await editImage(agentEditRequest('edit-completion-failure-key', 'state completion failure'));
|
| 707 |
+
|
| 708 |
+
assert.equal(response.status, 500);
|
| 709 |
+
const body = await response.json();
|
| 710 |
+
assert.equal(body.error.code, 'unexpected_error');
|
| 711 |
+
assert.equal(body.error.retryable, true);
|
| 712 |
+
assert.equal(body.error.request_id, requestId);
|
| 713 |
+
assert.equal(upstreamCalls, 1);
|
| 714 |
+
assert.equal(saveCalls, 1);
|
| 715 |
+
assert.equal(failCalls, 0);
|
| 716 |
+
} finally {
|
| 717 |
+
console.error = originalConsoleError;
|
| 718 |
+
await upstream.close();
|
| 719 |
+
}
|
| 720 |
+
});
|
| 721 |
+
|
| 722 |
it('requires artifact content authorization and returns image bytes when authorized', async () => {
|
| 723 |
const { generateImage, getArtifact, getArtifactContent, deleteArtifact } = await loadAgentRoutes();
|
| 724 |
const upstream = await startImageUpstream(() => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
|
|
|
| 839 |
}
|
| 840 |
};
|
| 841 |
},
|
| 842 |
+
async refreshRequestLease() {
|
| 843 |
+
return false;
|
| 844 |
+
},
|
| 845 |
async saveArtifacts() {
|
| 846 |
saveCalls += 1;
|
| 847 |
},
|
|
|
|
| 851 |
async failRequest() {
|
| 852 |
failCalls += 1;
|
| 853 |
},
|
| 854 |
+
async getRequest() {
|
| 855 |
+
return undefined;
|
| 856 |
+
},
|
| 857 |
async getArtifact() {
|
| 858 |
return undefined;
|
| 859 |
},
|
|
|
|
| 916 |
}
|
| 917 |
};
|
| 918 |
},
|
| 919 |
+
async refreshRequestLease() {
|
| 920 |
+
return false;
|
| 921 |
+
},
|
| 922 |
async saveArtifacts() {
|
| 923 |
throw new Error('artifact metadata save failed');
|
| 924 |
},
|
|
|
|
| 927 |
assert.equal(input.requestId, requestId);
|
| 928 |
assert.equal(input.error.error.retryable, true);
|
| 929 |
},
|
| 930 |
+
async getRequest() {
|
| 931 |
+
return undefined;
|
| 932 |
+
},
|
| 933 |
async getArtifact() {
|
| 934 |
return undefined;
|
| 935 |
},
|
|
|
|
| 1023 |
const artifactRoute = await import('./artifacts/[id]/route');
|
| 1024 |
const artifactContentRoute = await import('./artifacts/[id]/content/route');
|
| 1025 |
const capabilitiesRoute = await import('./capabilities/route');
|
| 1026 |
+
const createGenerateJobRoute = await import('./jobs/images/generate/route');
|
| 1027 |
+
const jobRoute = await import('./jobs/[id]/route');
|
| 1028 |
+
const jobResultRoute = await import('./jobs/[id]/result/route');
|
| 1029 |
return {
|
| 1030 |
getCapabilities: capabilitiesRoute.GET,
|
| 1031 |
generateImage: generateRoute.POST,
|
| 1032 |
editImage: editRoute.POST,
|
| 1033 |
+
createGenerateJob: createGenerateJobRoute.POST,
|
| 1034 |
+
getJob: jobRoute.GET,
|
| 1035 |
+
getJobResult: jobResultRoute.GET,
|
| 1036 |
getArtifact: artifactRoute.GET,
|
| 1037 |
deleteArtifact: artifactRoute.DELETE,
|
| 1038 |
getArtifactContent: artifactContentRoute.GET
|
|
|
|
| 1051 |
});
|
| 1052 |
}
|
| 1053 |
|
| 1054 |
+
function agentJobJsonRequest(idempotencyKey: string, body: Record<string, unknown>, headers: Record<string, string> = {}) {
|
| 1055 |
+
return new Request('http://localhost/api/agent/jobs/images/generate', {
|
| 1056 |
+
method: 'POST',
|
| 1057 |
+
headers: {
|
| 1058 |
+
'Content-Type': 'application/json',
|
| 1059 |
+
'Idempotency-Key': idempotencyKey,
|
| 1060 |
+
...headers
|
| 1061 |
+
},
|
| 1062 |
+
body: JSON.stringify(body)
|
| 1063 |
+
});
|
| 1064 |
+
}
|
| 1065 |
+
|
| 1066 |
function agentEditRequest(
|
| 1067 |
idempotencyKey: string,
|
| 1068 |
prompt: string,
|
|
|
|
| 1161 |
}
|
| 1162 |
throw new Error('等待条件超时');
|
| 1163 |
}
|
| 1164 |
+
|
| 1165 |
+
async function waitForJobResult(
|
| 1166 |
+
getJobResult: (
|
| 1167 |
+
request: Request,
|
| 1168 |
+
context: { params: Promise<{ id: string }> }
|
| 1169 |
+
) => Promise<Response>,
|
| 1170 |
+
id: string
|
| 1171 |
+
): Promise<Response> {
|
| 1172 |
+
for (let attempt = 0; attempt < 200; attempt += 1) {
|
| 1173 |
+
const response = await getJobResult(new Request(`http://localhost/api/agent/jobs/${id}/result`), {
|
| 1174 |
+
params: Promise.resolve({ id })
|
| 1175 |
+
});
|
| 1176 |
+
if (response.status !== 409) {
|
| 1177 |
+
return response;
|
| 1178 |
+
}
|
| 1179 |
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
| 1180 |
+
}
|
| 1181 |
+
throw new Error('等待 job result 超时');
|
| 1182 |
+
}
|
src/app/api/agent/images/edit/route.ts
CHANGED
|
@@ -39,11 +39,13 @@ export async function POST(request: NextRequest) {
|
|
| 39 |
|
| 40 |
if (beginResult.type === 'replay') {
|
| 41 |
const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
|
| 42 |
-
return NextResponse.json(response, {
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
if (beginResult.type === 'failed') {
|
| 45 |
requestId = beginResult.record.requestId;
|
| 46 |
-
return storedAgentErrorResponse(beginResult.error);
|
| 47 |
}
|
| 48 |
if (beginResult.type === 'conflict') {
|
| 49 |
throw new AgentApiError({
|
|
@@ -93,9 +95,7 @@ export async function POST(request: NextRequest) {
|
|
| 93 |
await completeAgentExecutionState(store, execution);
|
| 94 |
} catch (error) {
|
| 95 |
appLogger.error('保存 Agent 编辑完成状态失败。', error);
|
| 96 |
-
|
| 97 |
-
await store.failRequest({ requestId, error: errorToAgentErrorBody(persistenceError, requestId) });
|
| 98 |
-
throw persistenceError;
|
| 99 |
}
|
| 100 |
return NextResponse.json(execution.response, { headers });
|
| 101 |
} catch (error) {
|
|
|
|
| 39 |
|
| 40 |
if (beginResult.type === 'replay') {
|
| 41 |
const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
|
| 42 |
+
return NextResponse.json(response, {
|
| 43 |
+
headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId }
|
| 44 |
+
});
|
| 45 |
}
|
| 46 |
if (beginResult.type === 'failed') {
|
| 47 |
requestId = beginResult.record.requestId;
|
| 48 |
+
return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' });
|
| 49 |
}
|
| 50 |
if (beginResult.type === 'conflict') {
|
| 51 |
throw new AgentApiError({
|
|
|
|
| 95 |
await completeAgentExecutionState(store, execution);
|
| 96 |
} catch (error) {
|
| 97 |
appLogger.error('保存 Agent 编辑完成状态失败。', error);
|
| 98 |
+
throw createCompletionPersistenceError();
|
|
|
|
|
|
|
| 99 |
}
|
| 100 |
return NextResponse.json(execution.response, { headers });
|
| 101 |
} catch (error) {
|
src/app/api/agent/images/generate/route.ts
CHANGED
|
@@ -37,11 +37,13 @@ export async function POST(request: NextRequest) {
|
|
| 37 |
|
| 38 |
if (beginResult.type === 'replay') {
|
| 39 |
const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
|
| 40 |
-
return NextResponse.json(response, {
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
if (beginResult.type === 'failed') {
|
| 43 |
requestId = beginResult.record.requestId;
|
| 44 |
-
return storedAgentErrorResponse(beginResult.error);
|
| 45 |
}
|
| 46 |
if (beginResult.type === 'conflict') {
|
| 47 |
throw new AgentApiError({
|
|
|
|
| 37 |
|
| 38 |
if (beginResult.type === 'replay') {
|
| 39 |
const response = await hydrateAgentReplayResponse(store, beginResult.record, beginResult.response);
|
| 40 |
+
return NextResponse.json(response, {
|
| 41 |
+
headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': beginResult.record.requestId }
|
| 42 |
+
});
|
| 43 |
}
|
| 44 |
if (beginResult.type === 'failed') {
|
| 45 |
requestId = beginResult.record.requestId;
|
| 46 |
+
return storedAgentErrorResponse(beginResult.error, { 'X-Idempotent-Replay': 'true' });
|
| 47 |
}
|
| 48 |
if (beginResult.type === 'conflict') {
|
| 49 |
throw new AgentApiError({
|
src/app/api/agent/jobs/[id]/result/route.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
assertReadableJobRecord,
|
| 3 |
+
readAgentJobState,
|
| 4 |
+
readCompletedJobResult
|
| 5 |
+
} from '@/lib/agent-job-service';
|
| 6 |
+
import {
|
| 7 |
+
AgentApiError,
|
| 8 |
+
agentErrorResponse,
|
| 9 |
+
normalizeAgentError,
|
| 10 |
+
storedAgentErrorResponse
|
| 11 |
+
} from '@/lib/api-error-response';
|
| 12 |
+
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 13 |
+
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 14 |
+
import { computeRetryAfterSeconds, createRequestId } from '@/lib/agent-state-store';
|
| 15 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 16 |
+
|
| 17 |
+
type RouteContext = {
|
| 18 |
+
params: Promise<{ id: string }>;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export async function GET(request: NextRequest, context: RouteContext) {
|
| 22 |
+
let requestId = createRequestId();
|
| 23 |
+
try {
|
| 24 |
+
assertAgentAuthorized(request.headers);
|
| 25 |
+
const { id } = await context.params;
|
| 26 |
+
const store = await ensureAgentStateStoreReady();
|
| 27 |
+
const record = assertReadableJobRecord(await store.getRequest(id), id);
|
| 28 |
+
requestId = record.requestId;
|
| 29 |
+
const result = await readCompletedJobResult(store, record);
|
| 30 |
+
if (result.type === 'response') {
|
| 31 |
+
return NextResponse.json(result.response, { headers: { 'X-Request-Id': requestId } });
|
| 32 |
+
}
|
| 33 |
+
if (result.type === 'stored_error') {
|
| 34 |
+
return storedAgentErrorResponse(result.error);
|
| 35 |
+
}
|
| 36 |
+
throw createJobInProgressError(record);
|
| 37 |
+
} catch (error) {
|
| 38 |
+
return agentErrorResponse(normalizeAgentError(error), requestId);
|
| 39 |
+
}
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
function createJobInProgressError(record: Parameters<typeof readAgentJobState>[0]): AgentApiError {
|
| 43 |
+
const retryAfterSeconds = computeRetryAfterSeconds(record.lockedUntil, new Date());
|
| 44 |
+
return new AgentApiError({
|
| 45 |
+
code: 'request_in_progress',
|
| 46 |
+
message: 'Agent job 仍在运行。',
|
| 47 |
+
status: 409,
|
| 48 |
+
retryable: true,
|
| 49 |
+
retryAfterSeconds
|
| 50 |
+
});
|
| 51 |
+
}
|
src/app/api/agent/jobs/[id]/route.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { assertReadableJobRecord, buildAgentJobStatusResponse, readAgentJobState } from '@/lib/agent-job-service';
|
| 2 |
+
import { agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response';
|
| 3 |
+
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 4 |
+
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 5 |
+
import { createRequestId, computeRetryAfterSeconds } from '@/lib/agent-state-store';
|
| 6 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 7 |
+
|
| 8 |
+
type RouteContext = {
|
| 9 |
+
params: Promise<{ id: string }>;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export async function GET(request: NextRequest, context: RouteContext) {
|
| 13 |
+
let requestId = createRequestId();
|
| 14 |
+
try {
|
| 15 |
+
assertAgentAuthorized(request.headers);
|
| 16 |
+
const { id } = await context.params;
|
| 17 |
+
const store = await ensureAgentStateStoreReady();
|
| 18 |
+
const record = assertReadableJobRecord(await store.getRequest(id), id);
|
| 19 |
+
requestId = record.requestId;
|
| 20 |
+
const retryAfterSeconds = readRetryAfterSeconds(record);
|
| 21 |
+
return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), {
|
| 22 |
+
headers: { 'X-Request-Id': requestId }
|
| 23 |
+
});
|
| 24 |
+
} catch (error) {
|
| 25 |
+
return agentErrorResponse(normalizeAgentError(error), requestId);
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
function readRetryAfterSeconds(record: Parameters<typeof readAgentJobState>[0]): number | undefined {
|
| 30 |
+
if (readAgentJobState(record) !== 'running') return undefined;
|
| 31 |
+
return computeRetryAfterSeconds(record.lockedUntil, new Date());
|
| 32 |
+
}
|
src/app/api/agent/jobs/images/generate/route.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { buildAgentJobStatusResponse, startAgentGenerateJob } from '@/lib/agent-job-service';
|
| 2 |
+
import {
|
| 3 |
+
buildGenerateRequestHash,
|
| 4 |
+
parseAgentGenerateRequest,
|
| 5 |
+
readIdempotencyKey
|
| 6 |
+
} from '@/lib/agent-image-service';
|
| 7 |
+
import { readAgentLeaseMs, readAgentRequestTtlSeconds } from '@/lib/agent-api-contracts';
|
| 8 |
+
import { AgentApiError, agentErrorResponse, normalizeAgentError } from '@/lib/api-error-response';
|
| 9 |
+
import { assertAgentAuthorized } from '@/lib/agent-auth';
|
| 10 |
+
import { ensureAgentStateStoreReady } from '@/lib/agent-state-runtime';
|
| 11 |
+
import { createRequestId } from '@/lib/agent-state-store';
|
| 12 |
+
import { NextRequest, NextResponse } from 'next/server';
|
| 13 |
+
|
| 14 |
+
export async function POST(request: NextRequest) {
|
| 15 |
+
let requestId = createRequestId();
|
| 16 |
+
try {
|
| 17 |
+
assertAgentAuthorized(request.headers);
|
| 18 |
+
const imageRequest = await parseAgentGenerateRequest(request);
|
| 19 |
+
const idempotencyKey = readIdempotencyKey(request.headers);
|
| 20 |
+
const store = await ensureAgentStateStoreReady();
|
| 21 |
+
const leaseMs = readAgentLeaseMs(process.env);
|
| 22 |
+
const beginResult = await store.beginRequest({
|
| 23 |
+
idempotencyKey,
|
| 24 |
+
requestHash: buildGenerateRequestHash(imageRequest),
|
| 25 |
+
mode: 'generate',
|
| 26 |
+
requestJson: imageRequest,
|
| 27 |
+
leaseMs,
|
| 28 |
+
ttlSeconds: readAgentRequestTtlSeconds(process.env)
|
| 29 |
+
});
|
| 30 |
+
|
| 31 |
+
if (beginResult.type === 'conflict') {
|
| 32 |
+
throw createIdempotencyConflictError();
|
| 33 |
+
}
|
| 34 |
+
if (beginResult.type === 'replay' || beginResult.type === 'failed') {
|
| 35 |
+
requestId = beginResult.record.requestId;
|
| 36 |
+
return NextResponse.json(buildAgentJobStatusResponse(beginResult.record), {
|
| 37 |
+
headers: { 'X-Idempotent-Replay': 'true', 'X-Request-Id': requestId }
|
| 38 |
+
});
|
| 39 |
+
}
|
| 40 |
+
if (beginResult.type === 'in_progress') {
|
| 41 |
+
requestId = beginResult.record.requestId;
|
| 42 |
+
return runningJobResponse(beginResult.record, beginResult.retryAfterSeconds, true);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
requestId = beginResult.record.requestId;
|
| 46 |
+
startAgentGenerateJob({
|
| 47 |
+
store,
|
| 48 |
+
request: imageRequest,
|
| 49 |
+
headers: new Headers(request.headers),
|
| 50 |
+
requestId,
|
| 51 |
+
idempotencyKey,
|
| 52 |
+
leaseMs
|
| 53 |
+
});
|
| 54 |
+
return runningJobResponse(beginResult.record, 5, false);
|
| 55 |
+
} catch (error) {
|
| 56 |
+
return agentErrorResponse(normalizeAgentError(error), requestId);
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function createIdempotencyConflictError(): AgentApiError {
|
| 61 |
+
return new AgentApiError({
|
| 62 |
+
code: 'idempotency_conflict',
|
| 63 |
+
message: 'Idempotency-Key 已被不同请求正文使用。',
|
| 64 |
+
status: 409,
|
| 65 |
+
retryable: false
|
| 66 |
+
});
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function runningJobResponse(record: Parameters<typeof buildAgentJobStatusResponse>[0], retryAfterSeconds: number, replay: boolean) {
|
| 70 |
+
return NextResponse.json(buildAgentJobStatusResponse(record, { retryAfterSeconds }), {
|
| 71 |
+
status: 202,
|
| 72 |
+
headers: {
|
| 73 |
+
'Retry-After': String(retryAfterSeconds),
|
| 74 |
+
'X-Request-Id': record.requestId,
|
| 75 |
+
...(replay ? { 'X-Idempotent-Replay': 'true' } : {})
|
| 76 |
+
}
|
| 77 |
+
});
|
| 78 |
+
}
|
src/app/api/agent/openapi.json/route.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { buildAgentOpenApiDocument } from '@/lib/agent-
|
| 2 |
import { NextResponse } from 'next/server';
|
| 3 |
|
| 4 |
export async function GET() {
|
|
|
|
| 1 |
+
import { buildAgentOpenApiDocument } from '@/lib/agent-openapi';
|
| 2 |
import { NextResponse } from 'next/server';
|
| 3 |
|
| 4 |
export async function GET() {
|
src/app/api/auth-verify/route.test.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { afterEach, describe, it } from 'node:test';
|
|
| 5 |
import { NextRequest } from 'next/server';
|
| 6 |
|
| 7 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 8 |
-
const PAGE_PASSWORD_FIXTURE = ['customer', '
|
| 9 |
|
| 10 |
afterEach(() => {
|
| 11 |
if (originalAppPassword === undefined) {
|
|
@@ -16,7 +16,7 @@ afterEach(() => {
|
|
| 16 |
});
|
| 17 |
|
| 18 |
describe('POST /api/auth-verify', () => {
|
| 19 |
-
it('returns a page
|
| 20 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 21 |
const request = new NextRequest('http://localhost/api/auth-verify', {
|
| 22 |
method: 'POST',
|
|
|
|
| 5 |
import { NextRequest } from 'next/server';
|
| 6 |
|
| 7 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 8 |
+
const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
|
| 9 |
|
| 10 |
afterEach(() => {
|
| 11 |
if (originalAppPassword === undefined) {
|
|
|
|
| 16 |
});
|
| 17 |
|
| 18 |
describe('POST /api/auth-verify', () => {
|
| 19 |
+
it('returns a page access code error code for invalid access-code hashes', async () => {
|
| 20 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 21 |
const request = new NextRequest('http://localhost/api/auth-verify', {
|
| 22 |
method: 'POST',
|
src/app/api/auth-verify/route.ts
CHANGED
|
@@ -24,7 +24,7 @@ export async function POST(request: NextRequest) {
|
|
| 24 |
return NextResponse.json(
|
| 25 |
{
|
| 26 |
authenticated: false,
|
| 27 |
-
error: 'Unauthorized: Invalid
|
| 28 |
code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid
|
| 29 |
},
|
| 30 |
{ status: 401 }
|
|
|
|
| 24 |
return NextResponse.json(
|
| 25 |
{
|
| 26 |
authenticated: false,
|
| 27 |
+
error: 'Unauthorized: Invalid access code.',
|
| 28 |
code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid
|
| 29 |
},
|
| 30 |
{ status: 401 }
|
src/app/api/image-delete/route.ts
CHANGED
|
@@ -26,12 +26,12 @@ export async function POST(request: NextRequest) {
|
|
| 26 |
const clientPasswordHash = requestBody.passwordHash;
|
| 27 |
|
| 28 |
if (!clientPasswordHash) {
|
| 29 |
-
appLogger.error('删除操作缺少
|
| 30 |
-
return NextResponse.json({ error: '未授权:缺少
|
| 31 |
}
|
| 32 |
if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
|
| 33 |
-
appLogger.error('删除操作的
|
| 34 |
-
return NextResponse.json({ error: '未授权:
|
| 35 |
}
|
| 36 |
}
|
| 37 |
} catch (e) {
|
|
|
|
| 26 |
const clientPasswordHash = requestBody.passwordHash;
|
| 27 |
|
| 28 |
if (!clientPasswordHash) {
|
| 29 |
+
appLogger.error('删除操作缺少访问码哈希。');
|
| 30 |
+
return NextResponse.json({ error: '未授权:缺少访问码哈希。' }, { status: 401 });
|
| 31 |
}
|
| 32 |
if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
|
| 33 |
+
appLogger.error('删除操作的访问码哈希无效。');
|
| 34 |
+
return NextResponse.json({ error: '未授权:访问码无效。' }, { status: 401 });
|
| 35 |
}
|
| 36 |
}
|
| 37 |
} catch (e) {
|
src/app/api/image-route.test.ts
CHANGED
|
@@ -6,8 +6,8 @@ import { afterEach, describe, it } from 'node:test';
|
|
| 6 |
import { NextRequest } from 'next/server';
|
| 7 |
|
| 8 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 9 |
-
const PAGE_PASSWORD_FIXTURE = ['customer', '
|
| 10 |
-
const OTHER_PAGE_PASSWORD_FIXTURE = ['other', '
|
| 11 |
|
| 12 |
afterEach(() => {
|
| 13 |
if (originalAppPassword === undefined) {
|
|
@@ -18,7 +18,7 @@ afterEach(() => {
|
|
| 18 |
});
|
| 19 |
|
| 20 |
describe('GET /api/image/[filename]', () => {
|
| 21 |
-
it('returns a missing page
|
| 22 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 23 |
const request = new NextRequest('http://localhost/api/image/sample.png');
|
| 24 |
|
|
@@ -29,7 +29,7 @@ describe('GET /api/image/[filename]', () => {
|
|
| 29 |
assert.equal(result.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing);
|
| 30 |
});
|
| 31 |
|
| 32 |
-
it('returns an invalid page
|
| 33 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 34 |
const request = new NextRequest('http://localhost/api/image/sample.png', {
|
| 35 |
headers: {
|
|
|
|
| 6 |
import { NextRequest } from 'next/server';
|
| 7 |
|
| 8 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 9 |
+
const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
|
| 10 |
+
const OTHER_PAGE_PASSWORD_FIXTURE = ['other', 'access', 'code'].join('-');
|
| 11 |
|
| 12 |
afterEach(() => {
|
| 13 |
if (originalAppPassword === undefined) {
|
|
|
|
| 18 |
});
|
| 19 |
|
| 20 |
describe('GET /api/image/[filename]', () => {
|
| 21 |
+
it('returns a missing page access code error when the access cookie is absent', async () => {
|
| 22 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 23 |
const request = new NextRequest('http://localhost/api/image/sample.png');
|
| 24 |
|
|
|
|
| 29 |
assert.equal(result.code, PAGE_PASSWORD_AUTH_ERROR_CODES.missing);
|
| 30 |
});
|
| 31 |
|
| 32 |
+
it('returns an invalid page access code error when the access cookie is wrong', async () => {
|
| 33 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 34 |
const request = new NextRequest('http://localhost/api/image/sample.png', {
|
| 35 |
headers: {
|
src/app/api/images/route.ts
CHANGED
|
@@ -101,16 +101,16 @@ export async function POST(request: NextRequest) {
|
|
| 101 |
if (appPassword) {
|
| 102 |
const clientPasswordHash = formData.get('passwordHash');
|
| 103 |
if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) {
|
| 104 |
-
appLogger.error('缺少
|
| 105 |
return NextResponse.json(
|
| 106 |
-
{ error: '未授权:缺少
|
| 107 |
{ status: 401 }
|
| 108 |
);
|
| 109 |
}
|
| 110 |
if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
|
| 111 |
-
appLogger.error('
|
| 112 |
return NextResponse.json(
|
| 113 |
-
{ error: '未授权:
|
| 114 |
{ status: 401 }
|
| 115 |
);
|
| 116 |
}
|
|
|
|
| 101 |
if (appPassword) {
|
| 102 |
const clientPasswordHash = formData.get('passwordHash');
|
| 103 |
if (typeof clientPasswordHash !== 'string' || !clientPasswordHash) {
|
| 104 |
+
appLogger.error('缺少访问码哈希。', requestLogContext);
|
| 105 |
return NextResponse.json(
|
| 106 |
+
{ error: '未授权:缺少访问码哈希。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.missing },
|
| 107 |
{ status: 401 }
|
| 108 |
);
|
| 109 |
}
|
| 110 |
if (!verifyPasswordHash(clientPasswordHash, appPassword)) {
|
| 111 |
+
appLogger.error('访问码哈希无效。', requestLogContext);
|
| 112 |
return NextResponse.json(
|
| 113 |
+
{ error: '未授权:访问码无效。', code: PAGE_PASSWORD_AUTH_ERROR_CODES.invalid },
|
| 114 |
{ status: 401 }
|
| 115 |
);
|
| 116 |
}
|
src/app/api/logs/route.test.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { NextRequest } from 'next/server';
|
|
| 6 |
|
| 7 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 8 |
const originalAppLogLevel = process.env.APP_LOG_LEVEL;
|
| 9 |
-
const PAGE_PASSWORD_FIXTURE = ['customer', '
|
| 10 |
|
| 11 |
afterEach(() => {
|
| 12 |
if (originalAppPassword === undefined) {
|
|
@@ -35,7 +35,7 @@ describe('GET /api/logs', () => {
|
|
| 35 |
assert.equal(response.status, 403);
|
| 36 |
});
|
| 37 |
|
| 38 |
-
it('rejects
|
| 39 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 40 |
const request = new NextRequest(`http://localhost/api/logs?passwordHash=${sha256(PAGE_PASSWORD_FIXTURE)}`);
|
| 41 |
|
|
@@ -44,7 +44,7 @@ describe('GET /api/logs', () => {
|
|
| 44 |
assert.equal(response.status, 401);
|
| 45 |
});
|
| 46 |
|
| 47 |
-
it('accepts
|
| 48 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 49 |
process.env.APP_LOG_LEVEL = 'warn';
|
| 50 |
const request = new NextRequest('http://localhost/api/logs', {
|
|
|
|
| 6 |
|
| 7 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 8 |
const originalAppLogLevel = process.env.APP_LOG_LEVEL;
|
| 9 |
+
const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
|
| 10 |
|
| 11 |
afterEach(() => {
|
| 12 |
if (originalAppPassword === undefined) {
|
|
|
|
| 35 |
assert.equal(response.status, 403);
|
| 36 |
});
|
| 37 |
|
| 38 |
+
it('rejects access-code hashes sent in the query string', async () => {
|
| 39 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 40 |
const request = new NextRequest(`http://localhost/api/logs?passwordHash=${sha256(PAGE_PASSWORD_FIXTURE)}`);
|
| 41 |
|
|
|
|
| 44 |
assert.equal(response.status, 401);
|
| 45 |
});
|
| 46 |
|
| 47 |
+
it('accepts access-code hashes sent as a bearer token', async () => {
|
| 48 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 49 |
process.env.APP_LOG_LEVEL = 'warn';
|
| 50 |
const request = new NextRequest('http://localhost/api/logs', {
|
src/app/api/logs/route.ts
CHANGED
|
@@ -28,7 +28,7 @@ export async function GET(request: NextRequest) {
|
|
| 28 |
}
|
| 29 |
|
| 30 |
if (!isAuthorized(request)) {
|
| 31 |
-
return NextResponse.json({ error: '未授权:缺少或无效的
|
| 32 |
}
|
| 33 |
|
| 34 |
appLogger.info('日志查看器已连接。');
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
if (!isAuthorized(request)) {
|
| 31 |
+
return NextResponse.json({ error: '未授权:缺少或无效的访问码哈希。' }, { status: 401 });
|
| 32 |
}
|
| 33 |
|
| 34 |
appLogger.info('日志查看器已连接。');
|
src/app/api/shares/[token]/content/route.ts
CHANGED
|
@@ -62,7 +62,9 @@ function contentDispositionFilename(record: { accessCodeRequired: boolean; sourc
|
|
| 62 |
if (record.accessCodeRequired) {
|
| 63 |
return `shared-image${mimeExtension(record.mimeType)}`;
|
| 64 |
}
|
| 65 |
-
|
|
|
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
function pruneExpiredAccessFailures(now: number) {
|
|
@@ -122,6 +124,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ tok
|
|
| 122 |
Pragma: 'no-cache',
|
| 123 |
Expires: '0',
|
| 124 |
'Surrogate-Control': 'no-store',
|
|
|
|
| 125 |
'Content-Disposition': `inline; filename="${contentDispositionFilename(record)}"`
|
| 126 |
}
|
| 127 |
});
|
|
|
|
| 62 |
if (record.accessCodeRequired) {
|
| 63 |
return `shared-image${mimeExtension(record.mimeType)}`;
|
| 64 |
}
|
| 65 |
+
const sanitized = sanitizeHeaderFilename(record.sourceFilename);
|
| 66 |
+
const stem = sanitized.replace(/\.[^.]+$/, '') || 'shared-image';
|
| 67 |
+
return `${stem}${mimeExtension(record.mimeType)}`;
|
| 68 |
}
|
| 69 |
|
| 70 |
function pruneExpiredAccessFailures(now: number) {
|
|
|
|
| 124 |
Pragma: 'no-cache',
|
| 125 |
Expires: '0',
|
| 126 |
'Surrogate-Control': 'no-store',
|
| 127 |
+
'X-Content-Type-Options': 'nosniff',
|
| 128 |
'Content-Disposition': `inline; filename="${contentDispositionFilename(record)}"`
|
| 129 |
}
|
| 130 |
});
|
src/app/api/shares/route.test.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { GET as getShare } from './[token]/route';
|
|
| 13 |
import { POST } from './route';
|
| 14 |
|
| 15 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 16 |
-
const PAGE_PASSWORD_FIXTURE = ['customer', '
|
| 17 |
const VALID_PNG_BYTES = Buffer.from(
|
| 18 |
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mP8z8AABQMBgADeT7UAAAAASUVORK5CYII=',
|
| 19 |
'base64'
|
|
@@ -73,7 +73,7 @@ describe('POST /api/shares', { concurrency: false }, () => {
|
|
| 73 |
assert.equal('accessCodeSalt' in body, false);
|
| 74 |
});
|
| 75 |
|
| 76 |
-
it('rejects unauthenticated share creation when a page
|
| 77 |
await withTempCwd();
|
| 78 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 79 |
const form = new FormData();
|
|
@@ -99,7 +99,7 @@ describe('POST /api/shares', { concurrency: false }, () => {
|
|
| 99 |
assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
|
| 100 |
});
|
| 101 |
|
| 102 |
-
it('allows share creation when no page
|
| 103 |
await withTempCwd();
|
| 104 |
delete process.env.APP_PASSWORD;
|
| 105 |
const form = new FormData();
|
|
@@ -193,6 +193,45 @@ describe('POST /api/shares', { concurrency: false }, () => {
|
|
| 193 |
const body = await response.json();
|
| 194 |
assert.equal(body.code, 'invalid_expiry');
|
| 195 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
});
|
| 197 |
|
| 198 |
describe('share metadata and content routes', { concurrency: false }, () => {
|
|
@@ -272,10 +311,36 @@ describe('share metadata and content routes', { concurrency: false }, () => {
|
|
| 272 |
assert.equal(ok.headers.get('content-type'), 'image/png');
|
| 273 |
assert.match(ok.headers.get('cache-control') || '', /no-store/);
|
| 274 |
assert.equal(ok.headers.get('surrogate-control'), 'no-store');
|
|
|
|
| 275 |
assert.equal(ok.headers.get('content-disposition'), 'inline; filename="shared-image.png"');
|
| 276 |
assert.equal(await ok.text(), 'protected-image');
|
| 277 |
});
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
it('rate limits repeated wrong access codes', async () => {
|
| 280 |
await withTempCwd();
|
| 281 |
const record = await createImageShare({
|
|
|
|
| 13 |
import { POST } from './route';
|
| 14 |
|
| 15 |
const originalAppPassword = process.env.APP_PASSWORD;
|
| 16 |
+
const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
|
| 17 |
const VALID_PNG_BYTES = Buffer.from(
|
| 18 |
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVR42mP8z8AABQMBgADeT7UAAAAASUVORK5CYII=',
|
| 19 |
'base64'
|
|
|
|
| 73 |
assert.equal('accessCodeSalt' in body, false);
|
| 74 |
});
|
| 75 |
|
| 76 |
+
it('rejects unauthenticated share creation when a page access code is configured', async () => {
|
| 77 |
await withTempCwd();
|
| 78 |
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 79 |
const form = new FormData();
|
|
|
|
| 99 |
assert.equal(body.code, PAGE_PASSWORD_AUTH_ERROR_CODES.invalid);
|
| 100 |
});
|
| 101 |
|
| 102 |
+
it('allows share creation when no page access code is configured', async () => {
|
| 103 |
await withTempCwd();
|
| 104 |
delete process.env.APP_PASSWORD;
|
| 105 |
const form = new FormData();
|
|
|
|
| 193 |
const body = await response.json();
|
| 194 |
assert.equal(body.code, 'invalid_expiry');
|
| 195 |
});
|
| 196 |
+
|
| 197 |
+
it('rejects unsafe source filenames before storing share metadata', async () => {
|
| 198 |
+
await withTempCwd();
|
| 199 |
+
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 200 |
+
const form = new FormData();
|
| 201 |
+
form.set('sourceFilename', '../secret.png');
|
| 202 |
+
form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' }));
|
| 203 |
+
|
| 204 |
+
const response = await POST(createShareRequest(form));
|
| 205 |
+
assert.equal(response.status, 400);
|
| 206 |
+
const body = await response.json();
|
| 207 |
+
assert.equal(body.code, 'invalid_source_filename');
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
it('rejects non-string source filenames before storing share metadata', async () => {
|
| 211 |
+
await withTempCwd();
|
| 212 |
+
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 213 |
+
const form = new FormData();
|
| 214 |
+
form.set('sourceFilename', new File([Buffer.from('not-a-name')], 'name.txt', { type: 'text/plain' }));
|
| 215 |
+
form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' }));
|
| 216 |
+
|
| 217 |
+
const response = await POST(createShareRequest(form));
|
| 218 |
+
assert.equal(response.status, 400);
|
| 219 |
+
const body = await response.json();
|
| 220 |
+
assert.equal(body.code, 'invalid_source_filename');
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
it('rejects overlong source filenames before storing share metadata', async () => {
|
| 224 |
+
await withTempCwd();
|
| 225 |
+
process.env.APP_PASSWORD = PAGE_PASSWORD_FIXTURE;
|
| 226 |
+
const form = new FormData();
|
| 227 |
+
form.set('sourceFilename', `${'a'.repeat(201)}.png`);
|
| 228 |
+
form.set('image', new File([VALID_PNG_BYTES], 'result.png', { type: 'image/png' }));
|
| 229 |
+
|
| 230 |
+
const response = await POST(createShareRequest(form));
|
| 231 |
+
assert.equal(response.status, 400);
|
| 232 |
+
const body = await response.json();
|
| 233 |
+
assert.equal(body.code, 'invalid_source_filename');
|
| 234 |
+
});
|
| 235 |
});
|
| 236 |
|
| 237 |
describe('share metadata and content routes', { concurrency: false }, () => {
|
|
|
|
| 311 |
assert.equal(ok.headers.get('content-type'), 'image/png');
|
| 312 |
assert.match(ok.headers.get('cache-control') || '', /no-store/);
|
| 313 |
assert.equal(ok.headers.get('surrogate-control'), 'no-store');
|
| 314 |
+
assert.equal(ok.headers.get('x-content-type-options'), 'nosniff');
|
| 315 |
assert.equal(ok.headers.get('content-disposition'), 'inline; filename="shared-image.png"');
|
| 316 |
assert.equal(await ok.text(), 'protected-image');
|
| 317 |
});
|
| 318 |
|
| 319 |
+
it('uses the detected image MIME type for public content filenames', async () => {
|
| 320 |
+
await withTempCwd();
|
| 321 |
+
const record = await createImageShare({
|
| 322 |
+
imageBuffer: Buffer.from('public-image'),
|
| 323 |
+
sourceFilename: 'invoice.html',
|
| 324 |
+
mimeType: 'image/png',
|
| 325 |
+
accessCode: undefined,
|
| 326 |
+
expiresInMinutes: null
|
| 327 |
+
});
|
| 328 |
+
|
| 329 |
+
const response = await getShareContent(
|
| 330 |
+
new Request(`http://localhost/api/shares/${record.token}/content`, {
|
| 331 |
+
method: 'POST',
|
| 332 |
+
headers: { 'content-type': 'application/json' },
|
| 333 |
+
body: JSON.stringify({})
|
| 334 |
+
}),
|
| 335 |
+
params(record.token)
|
| 336 |
+
);
|
| 337 |
+
|
| 338 |
+
assert.equal(response.status, 200);
|
| 339 |
+
assert.equal(response.headers.get('content-type'), 'image/png');
|
| 340 |
+
assert.equal(response.headers.get('x-content-type-options'), 'nosniff');
|
| 341 |
+
assert.equal(response.headers.get('content-disposition'), 'inline; filename="invoice.png"');
|
| 342 |
+
});
|
| 343 |
+
|
| 344 |
it('rate limits repeated wrong access codes', async () => {
|
| 345 |
await withTempCwd();
|
| 346 |
const record = await createImageShare({
|
src/app/api/shares/route.ts
CHANGED
|
@@ -6,6 +6,8 @@ import { NextRequest, NextResponse } from 'next/server';
|
|
| 6 |
const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024;
|
| 7 |
const MIN_ACCESS_CODE_LENGTH = 8;
|
| 8 |
const MAX_ACCESS_CODE_LENGTH = 128;
|
|
|
|
|
|
|
| 9 |
|
| 10 |
function detectImageMimeType(buffer: Buffer): string | undefined {
|
| 11 |
if (
|
|
@@ -60,6 +62,15 @@ function parseAccessCode(value: FormDataEntryValue | null): string | undefined |
|
|
| 60 |
return trimmed;
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
function verifyShareCreator(request: NextRequest) {
|
| 64 |
if (!process.env.APP_PASSWORD) return undefined;
|
| 65 |
const accessToken = request.cookies.get('gptImageAccess')?.value;
|
|
@@ -89,7 +100,10 @@ export async function POST(request: NextRequest) {
|
|
| 89 |
|
| 90 |
const sourceFilenameValue = form.get('sourceFilename');
|
| 91 |
const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png';
|
| 92 |
-
const sourceFilename =
|
|
|
|
|
|
|
|
|
|
| 93 |
const expiresInMinutes = parseExpiry(form.get('expiresInMinutes'));
|
| 94 |
if (expiresInMinutes === undefined) {
|
| 95 |
return jsonError('invalid_expiry', '分享有效期无效。', 400);
|
|
|
|
| 6 |
const MAX_SHARE_IMAGE_BYTES = 30 * 1024 * 1024;
|
| 7 |
const MIN_ACCESS_CODE_LENGTH = 8;
|
| 8 |
const MAX_ACCESS_CODE_LENGTH = 128;
|
| 9 |
+
const MAX_SOURCE_FILENAME_LENGTH = 200;
|
| 10 |
+
const SOURCE_FILENAME_PATTERN = /^[^\x00-\x1f\x7f\\/]+$/u;
|
| 11 |
|
| 12 |
function detectImageMimeType(buffer: Buffer): string | undefined {
|
| 13 |
if (
|
|
|
|
| 62 |
return trimmed;
|
| 63 |
}
|
| 64 |
|
| 65 |
+
function parseSourceFilename(value: FormDataEntryValue | null, fallback: string): string | null {
|
| 66 |
+
if (value !== null && typeof value !== 'string') return null;
|
| 67 |
+
const candidate = value?.trim() || fallback.trim();
|
| 68 |
+
if (!candidate || candidate.length > MAX_SOURCE_FILENAME_LENGTH || !SOURCE_FILENAME_PATTERN.test(candidate)) {
|
| 69 |
+
return null;
|
| 70 |
+
}
|
| 71 |
+
return candidate;
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
function verifyShareCreator(request: NextRequest) {
|
| 75 |
if (!process.env.APP_PASSWORD) return undefined;
|
| 76 |
const accessToken = request.cookies.get('gptImageAccess')?.value;
|
|
|
|
| 100 |
|
| 101 |
const sourceFilenameValue = form.get('sourceFilename');
|
| 102 |
const fallbackFilename = typeof image.name === 'string' && image.name.trim() ? image.name : 'shared-image.png';
|
| 103 |
+
const sourceFilename = parseSourceFilename(sourceFilenameValue, fallbackFilename);
|
| 104 |
+
if (sourceFilename === null) {
|
| 105 |
+
return jsonError('invalid_source_filename', '分享文件名无效。', 400);
|
| 106 |
+
}
|
| 107 |
const expiresInMinutes = parseExpiry(form.get('expiresInMinutes'));
|
| 108 |
if (expiresInMinutes === undefined) {
|
| 109 |
return jsonError('invalid_expiry', '分享有效期无效。', 400);
|
src/app/page.tsx
CHANGED
|
@@ -471,7 +471,7 @@ export default function HomePage() {
|
|
| 471 |
}
|
| 472 |
return 'unavailable';
|
| 473 |
} catch (error) {
|
| 474 |
-
console.error('验证入口
|
| 475 |
return 'unavailable';
|
| 476 |
}
|
| 477 |
}, []);
|
|
@@ -683,7 +683,7 @@ export default function HomePage() {
|
|
| 683 |
await handleApiCall(...retryArgs, hash);
|
| 684 |
}
|
| 685 |
} catch (e) {
|
| 686 |
-
console.error('计算
|
| 687 |
setError(createErrorNotice(t('password.hashError')));
|
| 688 |
}
|
| 689 |
};
|
|
|
|
| 471 |
}
|
| 472 |
return 'unavailable';
|
| 473 |
} catch (error) {
|
| 474 |
+
console.error('验证入口访问码失败:', error);
|
| 475 |
return 'unavailable';
|
| 476 |
}
|
| 477 |
}, []);
|
|
|
|
| 683 |
await handleApiCall(...retryArgs, hash);
|
| 684 |
}
|
| 685 |
} catch (e) {
|
| 686 |
+
console.error('计算访问码哈希失败:', e);
|
| 687 |
setError(createErrorNotice(t('password.hashError')));
|
| 688 |
}
|
| 689 |
};
|
src/components/password-dialog.tsx
CHANGED
|
@@ -26,7 +26,7 @@ export function PasswordDialog({
|
|
| 26 |
isOpen,
|
| 27 |
onOpenChange,
|
| 28 |
onSave,
|
| 29 |
-
title = 'Configure
|
| 30 |
description
|
| 31 |
}: PasswordDialogProps) {
|
| 32 |
const { t } = useI18n();
|
|
|
|
| 26 |
isOpen,
|
| 27 |
onOpenChange,
|
| 28 |
onSave,
|
| 29 |
+
title = 'Configure Access Code',
|
| 30 |
description
|
| 31 |
}: PasswordDialogProps) {
|
| 32 |
const { t } = useI18n();
|
src/lib/agent-api-contracts.test.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
| 1 |
import {
|
|
|
|
| 2 |
buildAgentCapabilities,
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
validateAgentGenerateRequest
|
| 5 |
} from './agent-api-contracts';
|
|
|
|
| 6 |
import { RequestValidationError } from './image-request-utils';
|
| 7 |
import assert from 'node:assert/strict';
|
| 8 |
import { describe, it } from 'node:test';
|
|
@@ -75,6 +80,30 @@ describe('validateAgentGenerateRequest', () => {
|
|
| 75 |
});
|
| 76 |
});
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
describe('buildAgentCapabilities', () => {
|
| 79 |
it('exposes machine-readable defaults, limits, auth, and storage metadata', () => {
|
| 80 |
const capabilities = buildAgentCapabilities({
|
|
@@ -86,10 +115,50 @@ describe('buildAgentCapabilities', () => {
|
|
| 86 |
|
| 87 |
assert.equal(capabilities.defaults.state_backend, 'postgres');
|
| 88 |
assert.equal(capabilities.auth.required, true);
|
|
|
|
| 89 |
assert.equal(capabilities.storage.postgres_configured, true);
|
| 90 |
assert.equal('sqlite_path' in capabilities.storage, false);
|
| 91 |
assert.equal(capabilities.idempotency.header, 'Idempotency-Key');
|
| 92 |
assert.ok(capabilities.supported.models.includes('gpt-image-2'));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
});
|
| 94 |
|
| 95 |
it('exposes memory state backend for ephemeral deployments', () => {
|
|
@@ -108,13 +177,29 @@ describe('buildAgentCapabilities', () => {
|
|
| 108 |
assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]);
|
| 109 |
assert.ok('/api/agent/openapi.json' in document.paths);
|
| 110 |
assert.ok('/api/agent/images/generate' in document.paths);
|
|
|
|
|
|
|
|
|
|
| 111 |
assert.ok('AgentCapabilities' in document.components.schemas);
|
| 112 |
assert.ok('AgentImageResponse' in document.components.schemas);
|
|
|
|
| 113 |
assert.ok('AgentArtifact' in document.components.schemas);
|
| 114 |
assert.ok('EditRequest' in document.components.schemas);
|
| 115 |
assert.ok('AgentError' in document.components.schemas);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['200']);
|
|
|
|
|
|
|
| 117 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['422']);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
});
|
| 119 |
|
| 120 |
it('describes public capabilities without server-local SQLite paths', () => {
|
|
@@ -126,26 +211,47 @@ describe('buildAgentCapabilities', () => {
|
|
| 126 |
assert.equal('sqlite_path' in storageSchema.properties, false);
|
| 127 |
});
|
| 128 |
|
| 129 |
-
it('describes
|
| 130 |
-
const document = buildAgentOpenApiDocument({
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
assert.ok(document.components.securitySchemes.BearerAuth);
|
| 133 |
-
assert.
|
| 134 |
-
assert.deepEqual(document.
|
| 135 |
-
|
| 136 |
-
{ AppPasswordHash: [] }
|
| 137 |
-
]);
|
| 138 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['401']);
|
| 139 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['415']);
|
| 140 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['502']);
|
| 141 |
assert.ok(document.paths['/api/agent/images/edit'].post.responses['401']);
|
|
|
|
| 142 |
assert.ok(document.paths['/api/agent/images/edit'].post.responses['415']);
|
| 143 |
assert.ok(document.paths['/api/agent/images/edit'].post.responses['502']);
|
| 144 |
});
|
| 145 |
|
| 146 |
-
it('
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
const document = buildAgentOpenApiDocument({});
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].get.security, expectedSecurity);
|
| 151 |
assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].delete.security, expectedSecurity);
|
|
@@ -158,6 +264,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 158 |
const schema = document.components.schemas.AgentArtifact;
|
| 159 |
|
| 160 |
assert.equal(responseSchema.properties.artifact.$ref, '#/components/schemas/AgentArtifact');
|
|
|
|
| 161 |
assert.equal('filepath' in schema.properties, false);
|
| 162 |
assert.ok('content_url' in schema.properties);
|
| 163 |
assert.ok('metadata_url' in schema.properties);
|
|
|
|
| 1 |
import {
|
| 2 |
+
buildAgentAuthCapabilities,
|
| 3 |
buildAgentCapabilities,
|
| 4 |
+
readAgentLeaseMs,
|
| 5 |
+
readAgentPublicBaseUrl,
|
| 6 |
+
readAgentRecoveryIntervalMs,
|
| 7 |
+
readAgentRequestTtlSeconds,
|
| 8 |
validateAgentGenerateRequest
|
| 9 |
} from './agent-api-contracts';
|
| 10 |
+
import { buildAgentOpenApiDocument } from './agent-openapi';
|
| 11 |
import { RequestValidationError } from './image-request-utils';
|
| 12 |
import assert from 'node:assert/strict';
|
| 13 |
import { describe, it } from 'node:test';
|
|
|
|
| 80 |
});
|
| 81 |
});
|
| 82 |
|
| 83 |
+
describe('Agent numeric configuration', () => {
|
| 84 |
+
it('uses defaults when optional numeric env values are absent', () => {
|
| 85 |
+
assert.equal(readAgentRequestTtlSeconds({}), 86400);
|
| 86 |
+
assert.equal(readAgentLeaseMs({}), 600000);
|
| 87 |
+
assert.equal(readAgentRecoveryIntervalMs({}), 30000);
|
| 88 |
+
});
|
| 89 |
+
|
| 90 |
+
it('fails explicitly when numeric env values are invalid', () => {
|
| 91 |
+
assert.throws(() => readAgentRequestTtlSeconds({ AGENT_REQUEST_TTL_SECONDS: 'abc' }), /AGENT_REQUEST_TTL_SECONDS/);
|
| 92 |
+
assert.throws(() => readAgentLeaseMs({ AGENT_REQUEST_LEASE_MS: '0' }), /AGENT_REQUEST_LEASE_MS/);
|
| 93 |
+
assert.throws(() => readAgentRecoveryIntervalMs({ AGENT_RECOVERY_INTERVAL_MS: '-1' }), /AGENT_RECOVERY_INTERVAL_MS/);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
it('validates the public OpenAPI server URL', () => {
|
| 97 |
+
assert.equal(readAgentPublicBaseUrl({}), '/');
|
| 98 |
+
assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test/' }), 'https://images.example.test');
|
| 99 |
+
assert.equal(readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'http://localhost:4783' }), 'http://localhost:4783');
|
| 100 |
+
assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'not a url' }), /AGENT_PUBLIC_BASE_URL/);
|
| 101 |
+
assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'javascript:alert(1)' }), /AGENT_PUBLIC_BASE_URL/);
|
| 102 |
+
assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://user:pass@images.example.test' }), /AGENT_PUBLIC_BASE_URL/);
|
| 103 |
+
assert.throws(() => readAgentPublicBaseUrl({ AGENT_PUBLIC_BASE_URL: 'https://images.example.test?token=secret' }), /AGENT_PUBLIC_BASE_URL/);
|
| 104 |
+
});
|
| 105 |
+
});
|
| 106 |
+
|
| 107 |
describe('buildAgentCapabilities', () => {
|
| 108 |
it('exposes machine-readable defaults, limits, auth, and storage metadata', () => {
|
| 109 |
const capabilities = buildAgentCapabilities({
|
|
|
|
| 115 |
|
| 116 |
assert.equal(capabilities.defaults.state_backend, 'postgres');
|
| 117 |
assert.equal(capabilities.auth.required, true);
|
| 118 |
+
assert.deepEqual(capabilities.auth.schemes, ['bearer']);
|
| 119 |
assert.equal(capabilities.storage.postgres_configured, true);
|
| 120 |
assert.equal('sqlite_path' in capabilities.storage, false);
|
| 121 |
assert.equal(capabilities.idempotency.header, 'Idempotency-Key');
|
| 122 |
assert.ok(capabilities.supported.models.includes('gpt-image-2'));
|
| 123 |
+
assert.equal(capabilities.model_limits['gpt-image-2'].max_edge, 3840);
|
| 124 |
+
assert.equal(capabilities.model_limits['gpt-image-2'].edge_multiple, 16);
|
| 125 |
+
assert.equal(capabilities.model_limits['gpt-image-2'].max_pixels, 8294400);
|
| 126 |
+
assert.equal(capabilities.agent_streaming.generate.supported, false);
|
| 127 |
+
assert.equal(capabilities.agent_streaming.generate.mode, 'non_streaming_only');
|
| 128 |
+
assert.equal(capabilities.agent_streaming.page_sse.endpoint, '/api/images');
|
| 129 |
+
assert.equal(capabilities.endpoints.create_generate_job, '/api/agent/jobs/images/generate');
|
| 130 |
+
assert.equal(capabilities.agent_jobs.supported, true);
|
| 131 |
+
assert.equal(capabilities.agent_jobs.mode, 'job_polling');
|
| 132 |
+
assert.equal(capabilities.agent_jobs.endpoints.create_generate_job, '/api/agent/jobs/images/generate');
|
| 133 |
+
assert.deepEqual(capabilities.agent_jobs.states, ['queued', 'running', 'succeeded', 'failed', 'expired']);
|
| 134 |
+
assert.match(capabilities.agent_jobs.current_guidance, /poll/i);
|
| 135 |
+
});
|
| 136 |
+
|
| 137 |
+
it('exposes only the runtime-accepted bearer auth scheme when Agent token is configured', () => {
|
| 138 |
+
assert.deepEqual(
|
| 139 |
+
buildAgentAuthCapabilities({
|
| 140 |
+
AGENT_API_TOKEN: 'token',
|
| 141 |
+
APP_PASSWORD: 'page-access-code'
|
| 142 |
+
}),
|
| 143 |
+
{ required: true, schemes: ['bearer'] }
|
| 144 |
+
);
|
| 145 |
+
});
|
| 146 |
+
|
| 147 |
+
it('exposes access-code hash auth only when no Agent token is configured', () => {
|
| 148 |
+
assert.deepEqual(buildAgentAuthCapabilities({ APP_PASSWORD: 'page-access-code' }), {
|
| 149 |
+
required: true,
|
| 150 |
+
schemes: ['x-app-password-hash']
|
| 151 |
+
});
|
| 152 |
+
});
|
| 153 |
+
|
| 154 |
+
it('marks Agent auth as optional when no auth env is configured', () => {
|
| 155 |
+
assert.deepEqual(
|
| 156 |
+
buildAgentAuthCapabilities({
|
| 157 |
+
AGENT_API_TOKEN: ' ',
|
| 158 |
+
APP_PASSWORD: ' '
|
| 159 |
+
}),
|
| 160 |
+
{ required: false, schemes: [] }
|
| 161 |
+
);
|
| 162 |
});
|
| 163 |
|
| 164 |
it('exposes memory state backend for ephemeral deployments', () => {
|
|
|
|
| 177 |
assert.deepEqual(document.servers, [{ url: 'https://images.example.test' }]);
|
| 178 |
assert.ok('/api/agent/openapi.json' in document.paths);
|
| 179 |
assert.ok('/api/agent/images/generate' in document.paths);
|
| 180 |
+
assert.ok('/api/agent/jobs/images/generate' in document.paths);
|
| 181 |
+
assert.ok('/api/agent/jobs/{id}' in document.paths);
|
| 182 |
+
assert.ok('/api/agent/jobs/{id}/result' in document.paths);
|
| 183 |
assert.ok('AgentCapabilities' in document.components.schemas);
|
| 184 |
assert.ok('AgentImageResponse' in document.components.schemas);
|
| 185 |
+
assert.ok('AgentJobStatusResponse' in document.components.schemas);
|
| 186 |
assert.ok('AgentArtifact' in document.components.schemas);
|
| 187 |
assert.ok('EditRequest' in document.components.schemas);
|
| 188 |
assert.ok('AgentError' in document.components.schemas);
|
| 189 |
+
assert.ok('AgentModelLimits' in document.components.schemas);
|
| 190 |
+
assert.ok('AgentStreamingCapabilities' in document.components.schemas);
|
| 191 |
+
assert.ok('AgentJobCapabilities' in document.components.schemas);
|
| 192 |
+
assert.ok('AgentErrorDiagnostics' in document.components.schemas);
|
| 193 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['200']);
|
| 194 |
+
assert.ok(document.paths['/api/agent/images/generate'].post.responses['403']);
|
| 195 |
+
assert.ok(document.paths['/api/agent/images/generate'].post.responses['429']);
|
| 196 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['422']);
|
| 197 |
+
assert.ok(document.paths['/api/agent/jobs/images/generate'].post.responses['202']);
|
| 198 |
+
assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['200']);
|
| 199 |
+
assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['409']);
|
| 200 |
+
assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['422']);
|
| 201 |
+
assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['429']);
|
| 202 |
+
assert.ok(document.paths['/api/agent/jobs/{id}/result'].get.responses['502']);
|
| 203 |
});
|
| 204 |
|
| 205 |
it('describes public capabilities without server-local SQLite paths', () => {
|
|
|
|
| 211 |
assert.equal('sqlite_path' in storageSchema.properties, false);
|
| 212 |
});
|
| 213 |
|
| 214 |
+
it('describes bearer authentication and common runtime failures in OpenAPI', () => {
|
| 215 |
+
const document = buildAgentOpenApiDocument({
|
| 216 |
+
AGENT_API_TOKEN: 'token',
|
| 217 |
+
APP_PASSWORD: 'page-access-code'
|
| 218 |
+
});
|
| 219 |
|
| 220 |
assert.ok(document.components.securitySchemes.BearerAuth);
|
| 221 |
+
assert.equal('AppPasswordHash' in document.components.securitySchemes, false);
|
| 222 |
+
assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, ['bearer']);
|
| 223 |
+
assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ BearerAuth: [] }]);
|
|
|
|
|
|
|
| 224 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['401']);
|
| 225 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['415']);
|
| 226 |
assert.ok(document.paths['/api/agent/images/generate'].post.responses['502']);
|
| 227 |
assert.ok(document.paths['/api/agent/images/edit'].post.responses['401']);
|
| 228 |
+
assert.ok(document.paths['/api/agent/images/edit'].post.responses['409'].headers['Retry-After']);
|
| 229 |
assert.ok(document.paths['/api/agent/images/edit'].post.responses['415']);
|
| 230 |
assert.ok(document.paths['/api/agent/images/edit'].post.responses['502']);
|
| 231 |
});
|
| 232 |
|
| 233 |
+
it('describes access-code hash authentication in OpenAPI when no Agent token is configured', () => {
|
| 234 |
+
const document = buildAgentOpenApiDocument({ APP_PASSWORD: 'page-access-code' });
|
| 235 |
+
|
| 236 |
+
assert.equal('BearerAuth' in document.components.securitySchemes, false);
|
| 237 |
+
assert.ok(document.components.securitySchemes.AppPasswordHash);
|
| 238 |
+
assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, [
|
| 239 |
+
'x-app-password-hash'
|
| 240 |
+
]);
|
| 241 |
+
assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, [{ AppPasswordHash: [] }]);
|
| 242 |
+
});
|
| 243 |
+
|
| 244 |
+
it('does not require Agent authentication in OpenAPI when no auth env is configured', () => {
|
| 245 |
const document = buildAgentOpenApiDocument({});
|
| 246 |
+
|
| 247 |
+
assert.deepEqual(document.components.securitySchemes, {});
|
| 248 |
+
assert.deepEqual(document.components.schemas.AgentCapabilities.properties.auth.properties.schemes.const, []);
|
| 249 |
+
assert.deepEqual(document.paths['/api/agent/images/generate'].post.security, []);
|
| 250 |
+
});
|
| 251 |
+
|
| 252 |
+
it('marks artifact routes as authenticated in OpenAPI', () => {
|
| 253 |
+
const document = buildAgentOpenApiDocument({ AGENT_API_TOKEN: 'token' });
|
| 254 |
+
const expectedSecurity = [{ BearerAuth: [] }];
|
| 255 |
|
| 256 |
assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].get.security, expectedSecurity);
|
| 257 |
assert.deepEqual(document.paths['/api/agent/artifacts/{id}'].delete.security, expectedSecurity);
|
|
|
|
| 264 |
const schema = document.components.schemas.AgentArtifact;
|
| 265 |
|
| 266 |
assert.equal(responseSchema.properties.artifact.$ref, '#/components/schemas/AgentArtifact');
|
| 267 |
+
assert.equal('AgentArtifactRecord' in document.components.schemas, false);
|
| 268 |
assert.equal('filepath' in schema.properties, false);
|
| 269 |
assert.ok('content_url' in schema.properties);
|
| 270 |
assert.ok('metadata_url' in schema.properties);
|
src/lib/agent-api-contracts.ts
CHANGED
|
@@ -7,10 +7,18 @@ import {
|
|
| 7 |
type GptImageModel,
|
| 8 |
type ValidOutputFormat
|
| 9 |
} from './image-request-utils';
|
| 10 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
export const AGENT_API_VERSION = '1.0.0';
|
| 13 |
-
export const AGENT_SCHEMA_VERSION = '2026-05-
|
| 14 |
export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite';
|
| 15 |
export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000;
|
| 16 |
export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60;
|
|
@@ -23,12 +31,15 @@ export const AGENT_QUALITIES = ['low', 'medium', 'high', 'auto'] as const;
|
|
| 23 |
export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
|
| 24 |
export const AGENT_MODERATIONS = ['low', 'auto'] as const;
|
| 25 |
export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
|
|
|
|
| 26 |
|
| 27 |
export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres';
|
|
|
|
| 28 |
export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number];
|
| 29 |
export type AgentQuality = (typeof AGENT_QUALITIES)[number];
|
| 30 |
export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number];
|
| 31 |
export type AgentModeration = (typeof AGENT_MODERATIONS)[number];
|
|
|
|
| 32 |
|
| 33 |
export type AgentGenerateRequest = {
|
| 34 |
model: GptImageModel;
|
|
@@ -65,12 +76,35 @@ export type AgentImageResponse = {
|
|
| 65 |
created_at: string;
|
| 66 |
};
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
export type AgentCapabilities = {
|
| 69 |
api_version: string;
|
| 70 |
schema_version: string;
|
| 71 |
auth: {
|
| 72 |
required: boolean;
|
| 73 |
-
schemes:
|
| 74 |
};
|
| 75 |
endpoints: Record<string, string>;
|
| 76 |
defaults: {
|
|
@@ -84,6 +118,50 @@ export type AgentCapabilities = {
|
|
| 84 |
max_upload_mb: number;
|
| 85 |
partial_images: { min: number; max: number };
|
| 86 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
supported: {
|
| 88 |
models: readonly string[];
|
| 89 |
output_formats: readonly string[];
|
|
@@ -289,24 +367,15 @@ export function readAgentStateBackend(env: Record<string, string | undefined>):
|
|
| 289 |
}
|
| 290 |
|
| 291 |
export function readAgentRequestTtlSeconds(env: Record<string, string | undefined>): number {
|
| 292 |
-
|
| 293 |
-
if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_REQUEST_TTL_SECONDS;
|
| 294 |
-
const parsed = Number(value);
|
| 295 |
-
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_REQUEST_TTL_SECONDS;
|
| 296 |
}
|
| 297 |
|
| 298 |
export function readAgentLeaseMs(env: Record<string, string | undefined>): number {
|
| 299 |
-
|
| 300 |
-
if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_LEASE_MS;
|
| 301 |
-
const parsed = Number(value);
|
| 302 |
-
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_LEASE_MS;
|
| 303 |
}
|
| 304 |
|
| 305 |
export function readAgentRecoveryIntervalMs(env: Record<string, string | undefined>): number {
|
| 306 |
-
|
| 307 |
-
if (!value || !/^\d+$/.test(value)) return AGENT_DEFAULT_RECOVERY_INTERVAL_MS;
|
| 308 |
-
const parsed = Number(value);
|
| 309 |
-
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : AGENT_DEFAULT_RECOVERY_INTERVAL_MS;
|
| 310 |
}
|
| 311 |
|
| 312 |
export function readAgentSqlitePath(env: Record<string, string | undefined>): string {
|
|
@@ -314,26 +383,63 @@ export function readAgentSqlitePath(env: Record<string, string | undefined>): st
|
|
| 314 |
}
|
| 315 |
|
| 316 |
export function readAgentPublicBaseUrl(env: Record<string, string | undefined>): string {
|
| 317 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
}
|
| 319 |
|
| 320 |
export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void {
|
| 321 |
if (baseUrl) validateApiBaseUrl(baseUrl);
|
| 322 |
}
|
| 323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
export function buildAgentCapabilities(env: Record<string, string | undefined>): AgentCapabilities {
|
| 325 |
return {
|
| 326 |
api_version: AGENT_API_VERSION,
|
| 327 |
schema_version: AGENT_SCHEMA_VERSION,
|
| 328 |
-
auth:
|
| 329 |
-
required: Boolean(env.AGENT_API_TOKEN || env.APP_PASSWORD),
|
| 330 |
-
schemes: ['bearer', 'x-app-password-hash']
|
| 331 |
-
},
|
| 332 |
endpoints: {
|
| 333 |
capabilities: '/api/agent/capabilities',
|
| 334 |
openapi: '/api/agent/openapi.json',
|
| 335 |
generate: '/api/agent/images/generate',
|
| 336 |
edit: '/api/agent/images/edit',
|
|
|
|
|
|
|
|
|
|
| 337 |
artifact_metadata: '/api/agent/artifacts/{id}',
|
| 338 |
artifact_content: '/api/agent/artifacts/{id}/content',
|
| 339 |
artifact_delete: '/api/agent/artifacts/{id}'
|
|
@@ -349,6 +455,55 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 349 |
max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024,
|
| 350 |
partial_images: { min: 1, max: 3 }
|
| 351 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 352 |
supported: {
|
| 353 |
models: AGENT_MODELS,
|
| 354 |
output_formats: AGENT_OUTPUT_FORMATS,
|
|
@@ -369,314 +524,3 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 369 |
}
|
| 370 |
};
|
| 371 |
}
|
| 372 |
-
|
| 373 |
-
export function buildAgentOpenApiDocument(env: Record<string, string | undefined>) {
|
| 374 |
-
const capabilities = buildAgentCapabilities(env);
|
| 375 |
-
const jsonContent = (schemaRef: string) => ({
|
| 376 |
-
content: {
|
| 377 |
-
'application/json': {
|
| 378 |
-
schema: { $ref: schemaRef }
|
| 379 |
-
}
|
| 380 |
-
}
|
| 381 |
-
});
|
| 382 |
-
const agentSecurity = [{ BearerAuth: [] }, { AppPasswordHash: [] }];
|
| 383 |
-
const commonAgentErrors = {
|
| 384 |
-
'401': jsonContent('#/components/schemas/AgentError'),
|
| 385 |
-
'415': jsonContent('#/components/schemas/AgentError'),
|
| 386 |
-
'502': jsonContent('#/components/schemas/AgentError')
|
| 387 |
-
};
|
| 388 |
-
return {
|
| 389 |
-
openapi: '3.1.0',
|
| 390 |
-
info: {
|
| 391 |
-
title: 'GPT Image Playground Agent API',
|
| 392 |
-
version: capabilities.api_version
|
| 393 |
-
},
|
| 394 |
-
servers: [{ url: readAgentPublicBaseUrl(env) }],
|
| 395 |
-
paths: {
|
| 396 |
-
'/api/agent/capabilities': {
|
| 397 |
-
get: {
|
| 398 |
-
summary: '获取机器可读的 Agent API 能力信息',
|
| 399 |
-
responses: {
|
| 400 |
-
'200': jsonContent('#/components/schemas/AgentCapabilities')
|
| 401 |
-
}
|
| 402 |
-
}
|
| 403 |
-
},
|
| 404 |
-
'/api/agent/openapi.json': {
|
| 405 |
-
get: {
|
| 406 |
-
summary: '获取 Agent API 的 OpenAPI 文档',
|
| 407 |
-
responses: {
|
| 408 |
-
'200': { description: 'OpenAPI 文档' }
|
| 409 |
-
}
|
| 410 |
-
}
|
| 411 |
-
},
|
| 412 |
-
'/api/agent/images/generate': {
|
| 413 |
-
post: {
|
| 414 |
-
summary: '为 Agent 生成图片',
|
| 415 |
-
security: agentSecurity,
|
| 416 |
-
parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }],
|
| 417 |
-
requestBody: {
|
| 418 |
-
required: true,
|
| 419 |
-
...jsonContent('#/components/schemas/GenerateRequest')
|
| 420 |
-
},
|
| 421 |
-
responses: {
|
| 422 |
-
'200': jsonContent('#/components/schemas/AgentImageResponse'),
|
| 423 |
-
'400': jsonContent('#/components/schemas/AgentError'),
|
| 424 |
-
'409': {
|
| 425 |
-
...jsonContent('#/components/schemas/AgentError'),
|
| 426 |
-
headers: {
|
| 427 |
-
'Retry-After': { schema: { type: 'integer', minimum: 1 } }
|
| 428 |
-
}
|
| 429 |
-
},
|
| 430 |
-
...commonAgentErrors,
|
| 431 |
-
'422': jsonContent('#/components/schemas/AgentError'),
|
| 432 |
-
'500': jsonContent('#/components/schemas/AgentError')
|
| 433 |
-
}
|
| 434 |
-
}
|
| 435 |
-
},
|
| 436 |
-
'/api/agent/images/edit': {
|
| 437 |
-
post: {
|
| 438 |
-
summary: '为 Agent 编辑图片',
|
| 439 |
-
security: agentSecurity,
|
| 440 |
-
parameters: [{ $ref: '#/components/parameters/IdempotencyKey' }],
|
| 441 |
-
requestBody: {
|
| 442 |
-
required: true,
|
| 443 |
-
content: {
|
| 444 |
-
'multipart/form-data': {
|
| 445 |
-
schema: { $ref: '#/components/schemas/EditRequest' }
|
| 446 |
-
}
|
| 447 |
-
}
|
| 448 |
-
},
|
| 449 |
-
responses: {
|
| 450 |
-
'200': jsonContent('#/components/schemas/AgentImageResponse'),
|
| 451 |
-
'400': jsonContent('#/components/schemas/AgentError'),
|
| 452 |
-
'409': jsonContent('#/components/schemas/AgentError'),
|
| 453 |
-
...commonAgentErrors,
|
| 454 |
-
'422': jsonContent('#/components/schemas/AgentError'),
|
| 455 |
-
'500': jsonContent('#/components/schemas/AgentError')
|
| 456 |
-
}
|
| 457 |
-
}
|
| 458 |
-
},
|
| 459 |
-
'/api/agent/artifacts/{id}': {
|
| 460 |
-
get: {
|
| 461 |
-
summary: '获取产物元数据',
|
| 462 |
-
security: agentSecurity,
|
| 463 |
-
parameters: [{ $ref: '#/components/parameters/ArtifactId' }],
|
| 464 |
-
responses: {
|
| 465 |
-
'200': jsonContent('#/components/schemas/ArtifactMetadataResponse'),
|
| 466 |
-
'401': jsonContent('#/components/schemas/AgentError'),
|
| 467 |
-
'404': jsonContent('#/components/schemas/AgentError')
|
| 468 |
-
}
|
| 469 |
-
},
|
| 470 |
-
delete: {
|
| 471 |
-
summary: '删除产物',
|
| 472 |
-
security: agentSecurity,
|
| 473 |
-
parameters: [{ $ref: '#/components/parameters/ArtifactId' }],
|
| 474 |
-
responses: {
|
| 475 |
-
'200': jsonContent('#/components/schemas/DeleteArtifactResponse'),
|
| 476 |
-
'401': jsonContent('#/components/schemas/AgentError'),
|
| 477 |
-
'404': jsonContent('#/components/schemas/AgentError')
|
| 478 |
-
}
|
| 479 |
-
}
|
| 480 |
-
},
|
| 481 |
-
'/api/agent/artifacts/{id}/content': {
|
| 482 |
-
get: {
|
| 483 |
-
summary: '下载产物内容',
|
| 484 |
-
security: agentSecurity,
|
| 485 |
-
parameters: [{ $ref: '#/components/parameters/ArtifactId' }],
|
| 486 |
-
responses: {
|
| 487 |
-
'200': {
|
| 488 |
-
description: '图片二进制内容',
|
| 489 |
-
content: {
|
| 490 |
-
'image/png': { schema: { type: 'string', format: 'binary' } },
|
| 491 |
-
'image/jpeg': { schema: { type: 'string', format: 'binary' } },
|
| 492 |
-
'image/webp': { schema: { type: 'string', format: 'binary' } }
|
| 493 |
-
}
|
| 494 |
-
},
|
| 495 |
-
'401': jsonContent('#/components/schemas/AgentError'),
|
| 496 |
-
'404': jsonContent('#/components/schemas/AgentError')
|
| 497 |
-
}
|
| 498 |
-
}
|
| 499 |
-
}
|
| 500 |
-
},
|
| 501 |
-
components: {
|
| 502 |
-
securitySchemes: {
|
| 503 |
-
BearerAuth: {
|
| 504 |
-
type: 'http',
|
| 505 |
-
scheme: 'bearer'
|
| 506 |
-
},
|
| 507 |
-
AppPasswordHash: {
|
| 508 |
-
type: 'apiKey',
|
| 509 |
-
in: 'header',
|
| 510 |
-
name: 'X-App-Password-Hash'
|
| 511 |
-
}
|
| 512 |
-
},
|
| 513 |
-
parameters: {
|
| 514 |
-
IdempotencyKey: {
|
| 515 |
-
name: 'Idempotency-Key',
|
| 516 |
-
in: 'header',
|
| 517 |
-
required: true,
|
| 518 |
-
schema: { type: 'string', minLength: 1, maxLength: 200 }
|
| 519 |
-
},
|
| 520 |
-
ArtifactId: {
|
| 521 |
-
name: 'id',
|
| 522 |
-
in: 'path',
|
| 523 |
-
required: true,
|
| 524 |
-
schema: { type: 'string', minLength: 1 }
|
| 525 |
-
}
|
| 526 |
-
},
|
| 527 |
-
schemas: {
|
| 528 |
-
AgentCapabilities: {
|
| 529 |
-
type: 'object',
|
| 530 |
-
required: ['api_version', 'schema_version', 'auth', 'endpoints', 'defaults', 'limits', 'supported', 'storage', 'idempotency'],
|
| 531 |
-
properties: {
|
| 532 |
-
api_version: { type: 'string' },
|
| 533 |
-
schema_version: { type: 'string' },
|
| 534 |
-
auth: { type: 'object' },
|
| 535 |
-
endpoints: { type: 'object', additionalProperties: { type: 'string' } },
|
| 536 |
-
defaults: { type: 'object' },
|
| 537 |
-
limits: { type: 'object' },
|
| 538 |
-
supported: { type: 'object' },
|
| 539 |
-
storage: {
|
| 540 |
-
type: 'object',
|
| 541 |
-
required: ['image_storage_mode', 'postgres_configured'],
|
| 542 |
-
properties: {
|
| 543 |
-
image_storage_mode: { type: 'string' },
|
| 544 |
-
postgres_configured: { type: 'boolean' }
|
| 545 |
-
}
|
| 546 |
-
},
|
| 547 |
-
idempotency: { type: 'object' }
|
| 548 |
-
}
|
| 549 |
-
},
|
| 550 |
-
GenerateRequest: {
|
| 551 |
-
type: 'object',
|
| 552 |
-
required: ['prompt'],
|
| 553 |
-
properties: {
|
| 554 |
-
prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH },
|
| 555 |
-
model: { type: 'string', enum: AGENT_MODELS },
|
| 556 |
-
n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT },
|
| 557 |
-
size: { type: 'string' },
|
| 558 |
-
quality: { type: 'string', enum: AGENT_QUALITIES, default: 'high' },
|
| 559 |
-
output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS },
|
| 560 |
-
output_compression: { type: 'integer', minimum: 0, maximum: 100 },
|
| 561 |
-
background: { type: 'string', enum: AGENT_BACKGROUNDS },
|
| 562 |
-
moderation: { type: 'string', enum: AGENT_MODERATIONS },
|
| 563 |
-
response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' }
|
| 564 |
-
}
|
| 565 |
-
},
|
| 566 |
-
EditRequest: {
|
| 567 |
-
type: 'object',
|
| 568 |
-
required: ['prompt', 'image_0'],
|
| 569 |
-
properties: {
|
| 570 |
-
prompt: { type: 'string', maxLength: MAX_PROMPT_LENGTH },
|
| 571 |
-
model: { type: 'string', enum: AGENT_MODELS, default: 'gpt-image-2' },
|
| 572 |
-
n: { type: 'integer', minimum: 1, maximum: MAX_IMAGE_COUNT },
|
| 573 |
-
size: { type: 'string', default: 'auto' },
|
| 574 |
-
quality: { type: 'string', enum: AGENT_QUALITIES, default: 'auto' },
|
| 575 |
-
response_mode: { type: 'string', enum: AGENT_RESPONSE_MODES, default: 'path' },
|
| 576 |
-
image_0: { type: 'string', format: 'binary' },
|
| 577 |
-
mask: { type: 'string', format: 'binary' }
|
| 578 |
-
}
|
| 579 |
-
},
|
| 580 |
-
AgentArtifact: {
|
| 581 |
-
type: 'object',
|
| 582 |
-
required: ['id', 'filename', 'content_url', 'metadata_url', 'output_format', 'mime_type', 'size_bytes', 'width', 'height'],
|
| 583 |
-
properties: {
|
| 584 |
-
id: { type: 'string' },
|
| 585 |
-
filename: { type: 'string' },
|
| 586 |
-
content_url: { type: 'string' },
|
| 587 |
-
metadata_url: { type: 'string' },
|
| 588 |
-
output_format: { type: 'string', enum: AGENT_OUTPUT_FORMATS },
|
| 589 |
-
mime_type: { type: 'string' },
|
| 590 |
-
size_bytes: { type: 'integer', minimum: 0 },
|
| 591 |
-
width: { type: ['integer', 'null'] },
|
| 592 |
-
height: { type: ['integer', 'null'] },
|
| 593 |
-
b64_json: { type: 'string' }
|
| 594 |
-
}
|
| 595 |
-
},
|
| 596 |
-
AgentImageResponse: {
|
| 597 |
-
type: 'object',
|
| 598 |
-
required: ['request_id', 'idempotency_key', 'cached', 'images', 'created_at'],
|
| 599 |
-
properties: {
|
| 600 |
-
request_id: { type: 'string' },
|
| 601 |
-
idempotency_key: { type: 'string' },
|
| 602 |
-
cached: { type: 'boolean' },
|
| 603 |
-
images: {
|
| 604 |
-
type: 'array',
|
| 605 |
-
items: { $ref: '#/components/schemas/AgentArtifact' }
|
| 606 |
-
},
|
| 607 |
-
usage: { type: 'object' },
|
| 608 |
-
created_at: { type: 'string', format: 'date-time' }
|
| 609 |
-
}
|
| 610 |
-
},
|
| 611 |
-
ArtifactMetadataResponse: {
|
| 612 |
-
type: 'object',
|
| 613 |
-
required: ['artifact'],
|
| 614 |
-
properties: {
|
| 615 |
-
artifact: { $ref: '#/components/schemas/AgentArtifact' }
|
| 616 |
-
}
|
| 617 |
-
},
|
| 618 |
-
AgentArtifactRecord: {
|
| 619 |
-
type: 'object',
|
| 620 |
-
required: [
|
| 621 |
-
'id',
|
| 622 |
-
'requestId',
|
| 623 |
-
'filename',
|
| 624 |
-
'filepath',
|
| 625 |
-
'contentUrl',
|
| 626 |
-
'metadataUrl',
|
| 627 |
-
'outputFormat',
|
| 628 |
-
'mimeType',
|
| 629 |
-
'sizeBytes',
|
| 630 |
-
'width',
|
| 631 |
-
'height',
|
| 632 |
-
'model',
|
| 633 |
-
'promptHash',
|
| 634 |
-
'createdAt'
|
| 635 |
-
],
|
| 636 |
-
properties: {
|
| 637 |
-
id: { type: 'string' },
|
| 638 |
-
requestId: { type: 'string' },
|
| 639 |
-
filename: { type: 'string' },
|
| 640 |
-
filepath: { type: 'string' },
|
| 641 |
-
contentUrl: { type: 'string' },
|
| 642 |
-
metadataUrl: { type: 'string' },
|
| 643 |
-
outputFormat: { type: 'string', enum: AGENT_OUTPUT_FORMATS },
|
| 644 |
-
mimeType: { type: 'string' },
|
| 645 |
-
sizeBytes: { type: 'integer', minimum: 0 },
|
| 646 |
-
width: { type: ['integer', 'null'] },
|
| 647 |
-
height: { type: ['integer', 'null'] },
|
| 648 |
-
model: { type: 'string' },
|
| 649 |
-
promptHash: { type: 'string' },
|
| 650 |
-
createdAt: { type: 'string', format: 'date-time' }
|
| 651 |
-
}
|
| 652 |
-
},
|
| 653 |
-
DeleteArtifactResponse: {
|
| 654 |
-
type: 'object',
|
| 655 |
-
required: ['deleted', 'id'],
|
| 656 |
-
properties: {
|
| 657 |
-
deleted: { type: 'boolean' },
|
| 658 |
-
id: { type: 'string' }
|
| 659 |
-
}
|
| 660 |
-
},
|
| 661 |
-
AgentError: {
|
| 662 |
-
type: 'object',
|
| 663 |
-
required: ['error'],
|
| 664 |
-
properties: {
|
| 665 |
-
error: {
|
| 666 |
-
type: 'object',
|
| 667 |
-
required: ['code', 'message', 'retryable', 'request_id'],
|
| 668 |
-
properties: {
|
| 669 |
-
code: { type: 'string' },
|
| 670 |
-
message: { type: 'string' },
|
| 671 |
-
retryable: { type: 'boolean' },
|
| 672 |
-
details: { type: 'object' },
|
| 673 |
-
upstream_status: { type: 'integer' },
|
| 674 |
-
request_id: { type: 'string' }
|
| 675 |
-
}
|
| 676 |
-
}
|
| 677 |
-
}
|
| 678 |
-
}
|
| 679 |
-
}
|
| 680 |
-
}
|
| 681 |
-
};
|
| 682 |
-
}
|
|
|
|
| 7 |
type GptImageModel,
|
| 8 |
type ValidOutputFormat
|
| 9 |
} from './image-request-utils';
|
| 10 |
+
import {
|
| 11 |
+
GPT_IMAGE_2_EDGE_MULTIPLE,
|
| 12 |
+
GPT_IMAGE_2_MAX_ASPECT,
|
| 13 |
+
GPT_IMAGE_2_MAX_EDGE,
|
| 14 |
+
GPT_IMAGE_2_MAX_PIXELS,
|
| 15 |
+
GPT_IMAGE_2_MIN_PIXELS,
|
| 16 |
+
validateGptImage2Size
|
| 17 |
+
} from './size-utils';
|
| 18 |
+
import type { AgentErrorDiagnostics } from './api-error-response';
|
| 19 |
|
| 20 |
export const AGENT_API_VERSION = '1.0.0';
|
| 21 |
+
export const AGENT_SCHEMA_VERSION = '2026-05-20';
|
| 22 |
export const AGENT_DEFAULT_SQLITE_PATH = 'generated-images/.agent-state/agent.sqlite';
|
| 23 |
export const AGENT_DEFAULT_LEASE_MS = 10 * 60 * 1000;
|
| 24 |
export const AGENT_DEFAULT_REQUEST_TTL_SECONDS = 24 * 60 * 60;
|
|
|
|
| 31 |
export const AGENT_BACKGROUNDS = ['transparent', 'opaque', 'auto'] as const;
|
| 32 |
export const AGENT_MODERATIONS = ['low', 'auto'] as const;
|
| 33 |
export const AGENT_LEGACY_SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
|
| 34 |
+
export const AGENT_JOB_STATES = ['queued', 'running', 'succeeded', 'failed', 'expired'] as const;
|
| 35 |
|
| 36 |
export type AgentStateBackend = 'memory' | 'sqlite' | 'postgres';
|
| 37 |
+
export type AgentAuthScheme = 'bearer' | 'x-app-password-hash';
|
| 38 |
export type AgentResponseMode = (typeof AGENT_RESPONSE_MODES)[number];
|
| 39 |
export type AgentQuality = (typeof AGENT_QUALITIES)[number];
|
| 40 |
export type AgentBackground = (typeof AGENT_BACKGROUNDS)[number];
|
| 41 |
export type AgentModeration = (typeof AGENT_MODERATIONS)[number];
|
| 42 |
+
export type AgentJobState = (typeof AGENT_JOB_STATES)[number];
|
| 43 |
|
| 44 |
export type AgentGenerateRequest = {
|
| 45 |
model: GptImageModel;
|
|
|
|
| 76 |
created_at: string;
|
| 77 |
};
|
| 78 |
|
| 79 |
+
export type AgentJobStatusResponse = {
|
| 80 |
+
job: {
|
| 81 |
+
id: string;
|
| 82 |
+
request_id: string;
|
| 83 |
+
idempotency_key: string;
|
| 84 |
+
mode: 'generate' | 'edit';
|
| 85 |
+
state: AgentJobState;
|
| 86 |
+
created_at: string;
|
| 87 |
+
updated_at: string;
|
| 88 |
+
expires_at: string;
|
| 89 |
+
result_url?: string;
|
| 90 |
+
retry_after_seconds?: number;
|
| 91 |
+
error?: {
|
| 92 |
+
code: string;
|
| 93 |
+
message: string;
|
| 94 |
+
retryable: boolean;
|
| 95 |
+
details?: Record<string, unknown>;
|
| 96 |
+
upstream_status?: number;
|
| 97 |
+
diagnostics?: AgentErrorDiagnostics;
|
| 98 |
+
};
|
| 99 |
+
};
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
export type AgentCapabilities = {
|
| 103 |
api_version: string;
|
| 104 |
schema_version: string;
|
| 105 |
auth: {
|
| 106 |
required: boolean;
|
| 107 |
+
schemes: AgentAuthScheme[];
|
| 108 |
};
|
| 109 |
endpoints: Record<string, string>;
|
| 110 |
defaults: {
|
|
|
|
| 118 |
max_upload_mb: number;
|
| 119 |
partial_images: { min: number; max: number };
|
| 120 |
};
|
| 121 |
+
model_limits: {
|
| 122 |
+
'gpt-image-2': {
|
| 123 |
+
max_edge: number;
|
| 124 |
+
max_pixels: number;
|
| 125 |
+
edge_multiple: number;
|
| 126 |
+
max_aspect: number;
|
| 127 |
+
min_pixels: number;
|
| 128 |
+
recommended_presets: Array<{ name: string; size: string; purpose: string }>;
|
| 129 |
+
high_4k_risk: {
|
| 130 |
+
applies_to: string[];
|
| 131 |
+
guidance: string;
|
| 132 |
+
};
|
| 133 |
+
};
|
| 134 |
+
};
|
| 135 |
+
agent_streaming: {
|
| 136 |
+
generate: {
|
| 137 |
+
supported: false;
|
| 138 |
+
mode: 'non_streaming_only';
|
| 139 |
+
endpoint: string;
|
| 140 |
+
};
|
| 141 |
+
edit: {
|
| 142 |
+
supported: false;
|
| 143 |
+
mode: 'non_streaming_only';
|
| 144 |
+
endpoint: string;
|
| 145 |
+
};
|
| 146 |
+
page_sse: {
|
| 147 |
+
supported: true;
|
| 148 |
+
mode: 'form_data_sse';
|
| 149 |
+
endpoint: string;
|
| 150 |
+
contract: 'page_ui_only';
|
| 151 |
+
};
|
| 152 |
+
};
|
| 153 |
+
agent_jobs: {
|
| 154 |
+
supported: true;
|
| 155 |
+
mode: 'job_polling';
|
| 156 |
+
intended_for: string[];
|
| 157 |
+
endpoints: {
|
| 158 |
+
create_generate_job: string;
|
| 159 |
+
get_job: string;
|
| 160 |
+
get_job_result: string;
|
| 161 |
+
};
|
| 162 |
+
states: readonly string[];
|
| 163 |
+
current_guidance: string;
|
| 164 |
+
};
|
| 165 |
supported: {
|
| 166 |
models: readonly string[];
|
| 167 |
output_formats: readonly string[];
|
|
|
|
| 367 |
}
|
| 368 |
|
| 369 |
export function readAgentRequestTtlSeconds(env: Record<string, string | undefined>): number {
|
| 370 |
+
return readPositiveIntegerEnv(env, 'AGENT_REQUEST_TTL_SECONDS', AGENT_DEFAULT_REQUEST_TTL_SECONDS);
|
|
|
|
|
|
|
|
|
|
| 371 |
}
|
| 372 |
|
| 373 |
export function readAgentLeaseMs(env: Record<string, string | undefined>): number {
|
| 374 |
+
return readPositiveIntegerEnv(env, 'AGENT_REQUEST_LEASE_MS', AGENT_DEFAULT_LEASE_MS);
|
|
|
|
|
|
|
|
|
|
| 375 |
}
|
| 376 |
|
| 377 |
export function readAgentRecoveryIntervalMs(env: Record<string, string | undefined>): number {
|
| 378 |
+
return readPositiveIntegerEnv(env, 'AGENT_RECOVERY_INTERVAL_MS', AGENT_DEFAULT_RECOVERY_INTERVAL_MS);
|
|
|
|
|
|
|
|
|
|
| 379 |
}
|
| 380 |
|
| 381 |
export function readAgentSqlitePath(env: Record<string, string | undefined>): string {
|
|
|
|
| 383 |
}
|
| 384 |
|
| 385 |
export function readAgentPublicBaseUrl(env: Record<string, string | undefined>): string {
|
| 386 |
+
const value = env.AGENT_PUBLIC_BASE_URL?.trim();
|
| 387 |
+
if (!value) return '/';
|
| 388 |
+
let parsed: URL;
|
| 389 |
+
try {
|
| 390 |
+
parsed = new URL(value);
|
| 391 |
+
} catch {
|
| 392 |
+
throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 格式无效。', 500);
|
| 393 |
+
}
|
| 394 |
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
| 395 |
+
throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 必须是 http 或 https URL。', 500);
|
| 396 |
+
}
|
| 397 |
+
if (parsed.username || parsed.password || parsed.search || parsed.hash) {
|
| 398 |
+
throw new RequestValidationError('AGENT_PUBLIC_BASE_URL 不能包含凭据、查询参数或片段。', 500);
|
| 399 |
+
}
|
| 400 |
+
return parsed.toString().replace(/\/$/, '');
|
| 401 |
}
|
| 402 |
|
| 403 |
export function validateOptionalAgentApiBaseUrl(baseUrl: string | undefined): void {
|
| 404 |
if (baseUrl) validateApiBaseUrl(baseUrl);
|
| 405 |
}
|
| 406 |
|
| 407 |
+
export function buildAgentAuthCapabilities(env: Record<string, string | undefined>): AgentCapabilities['auth'] {
|
| 408 |
+
if (env.AGENT_API_TOKEN?.trim()) {
|
| 409 |
+
return { required: true, schemes: ['bearer'] };
|
| 410 |
+
}
|
| 411 |
+
if (env.APP_PASSWORD?.trim()) {
|
| 412 |
+
return { required: true, schemes: ['x-app-password-hash'] };
|
| 413 |
+
}
|
| 414 |
+
return { required: false, schemes: [] };
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
function readPositiveIntegerEnv(env: Record<string, string | undefined>, fieldName: string, fallback: number): number {
|
| 418 |
+
const value = env[fieldName]?.trim();
|
| 419 |
+
if (!value) return fallback;
|
| 420 |
+
if (!/^\d+$/.test(value)) {
|
| 421 |
+
throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500);
|
| 422 |
+
}
|
| 423 |
+
const parsed = Number(value);
|
| 424 |
+
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
| 425 |
+
throw new RequestValidationError(`${fieldName} 必须是正整数。`, 500);
|
| 426 |
+
}
|
| 427 |
+
return parsed;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
export function buildAgentCapabilities(env: Record<string, string | undefined>): AgentCapabilities {
|
| 431 |
return {
|
| 432 |
api_version: AGENT_API_VERSION,
|
| 433 |
schema_version: AGENT_SCHEMA_VERSION,
|
| 434 |
+
auth: buildAgentAuthCapabilities(env),
|
|
|
|
|
|
|
|
|
|
| 435 |
endpoints: {
|
| 436 |
capabilities: '/api/agent/capabilities',
|
| 437 |
openapi: '/api/agent/openapi.json',
|
| 438 |
generate: '/api/agent/images/generate',
|
| 439 |
edit: '/api/agent/images/edit',
|
| 440 |
+
create_generate_job: '/api/agent/jobs/images/generate',
|
| 441 |
+
job: '/api/agent/jobs/{id}',
|
| 442 |
+
job_result: '/api/agent/jobs/{id}/result',
|
| 443 |
artifact_metadata: '/api/agent/artifacts/{id}',
|
| 444 |
artifact_content: '/api/agent/artifacts/{id}/content',
|
| 445 |
artifact_delete: '/api/agent/artifacts/{id}'
|
|
|
|
| 455 |
max_upload_mb: MAX_UPLOAD_BYTES / 1024 / 1024,
|
| 456 |
partial_images: { min: 1, max: 3 }
|
| 457 |
},
|
| 458 |
+
model_limits: {
|
| 459 |
+
'gpt-image-2': {
|
| 460 |
+
max_edge: GPT_IMAGE_2_MAX_EDGE,
|
| 461 |
+
max_pixels: GPT_IMAGE_2_MAX_PIXELS,
|
| 462 |
+
edge_multiple: GPT_IMAGE_2_EDGE_MULTIPLE,
|
| 463 |
+
max_aspect: GPT_IMAGE_2_MAX_ASPECT,
|
| 464 |
+
min_pixels: GPT_IMAGE_2_MIN_PIXELS,
|
| 465 |
+
recommended_presets: [
|
| 466 |
+
{ name: 'square', size: '2048x2048', purpose: '通用正方形构图' },
|
| 467 |
+
{ name: 'landscape', size: '3072x2048', purpose: '横向宽幅构图' },
|
| 468 |
+
{ name: 'portrait', size: '2048x3072', purpose: '纵向主体构图' }
|
| 469 |
+
],
|
| 470 |
+
high_4k_risk: {
|
| 471 |
+
applies_to: ['quality=high', 'max_edge>=3072', 'long_running_upstream'],
|
| 472 |
+
guidance: '高质量 4K 级请求可能耗时数分钟;失败应归类为上游长耗时风险,不代表低负载路径不可用。'
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
},
|
| 476 |
+
agent_streaming: {
|
| 477 |
+
generate: {
|
| 478 |
+
supported: false,
|
| 479 |
+
mode: 'non_streaming_only',
|
| 480 |
+
endpoint: '/api/agent/images/generate'
|
| 481 |
+
},
|
| 482 |
+
edit: {
|
| 483 |
+
supported: false,
|
| 484 |
+
mode: 'non_streaming_only',
|
| 485 |
+
endpoint: '/api/agent/images/edit'
|
| 486 |
+
},
|
| 487 |
+
page_sse: {
|
| 488 |
+
supported: true,
|
| 489 |
+
mode: 'form_data_sse',
|
| 490 |
+
endpoint: '/api/images',
|
| 491 |
+
contract: 'page_ui_only'
|
| 492 |
+
}
|
| 493 |
+
},
|
| 494 |
+
agent_jobs: {
|
| 495 |
+
supported: true,
|
| 496 |
+
mode: 'job_polling',
|
| 497 |
+
intended_for: ['quality=high', 'max_edge>=3072', 'long_running_upstream', 'manual_billable_gate'],
|
| 498 |
+
endpoints: {
|
| 499 |
+
create_generate_job: '/api/agent/jobs/images/generate',
|
| 500 |
+
get_job: '/api/agent/jobs/{id}',
|
| 501 |
+
get_job_result: '/api/agent/jobs/{id}/result'
|
| 502 |
+
},
|
| 503 |
+
states: AGENT_JOB_STATES,
|
| 504 |
+
current_guidance:
|
| 505 |
+
'对 4K/high 或长耗时请求优先使用 job polling:先创建 generate job,再轮询状态,最后读取 result。运行中 job 会刷新 lease。当前执行模型为同实例后台任务,不是跨实例持久队列。'
|
| 506 |
+
},
|
| 507 |
supported: {
|
| 508 |
models: AGENT_MODELS,
|
| 509 |
output_formats: AGENT_OUTPUT_FORMATS,
|
|
|
|
| 524 |
}
|
| 525 |
};
|
| 526 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/lib/agent-auth.test.ts
CHANGED
|
@@ -4,7 +4,7 @@ import assert from 'node:assert/strict';
|
|
| 4 |
import crypto from 'node:crypto';
|
| 5 |
import { describe, it } from 'node:test';
|
| 6 |
|
| 7 |
-
const PAGE_PASSWORD_FIXTURE = ['customer', '
|
| 8 |
|
| 9 |
describe('assertAgentAuthorized', () => {
|
| 10 |
it('accepts the configured bearer token', () => {
|
|
@@ -20,14 +20,14 @@ describe('assertAgentAuthorized', () => {
|
|
| 20 |
);
|
| 21 |
});
|
| 22 |
|
| 23 |
-
it('rejects missing bearer tokens without falling back to
|
| 24 |
assert.throws(
|
| 25 |
-
() => assertAgentAuthorized(new Headers(), { AGENT_API_TOKEN: 'secret-token', APP_PASSWORD: '
|
| 26 |
(error) => error instanceof AgentApiError && error.code === 'unauthorized'
|
| 27 |
);
|
| 28 |
});
|
| 29 |
|
| 30 |
-
it('trims APP_PASSWORD before verifying
|
| 31 |
const passwordHash = crypto.createHash('sha256').update(PAGE_PASSWORD_FIXTURE).digest('hex');
|
| 32 |
assert.doesNotThrow(() =>
|
| 33 |
assertAgentAuthorized(new Headers({ 'X-App-Password-Hash': passwordHash }), {
|
|
|
|
| 4 |
import crypto from 'node:crypto';
|
| 5 |
import { describe, it } from 'node:test';
|
| 6 |
|
| 7 |
+
const PAGE_PASSWORD_FIXTURE = ['customer', 'access', 'code'].join('-');
|
| 8 |
|
| 9 |
describe('assertAgentAuthorized', () => {
|
| 10 |
it('accepts the configured bearer token', () => {
|
|
|
|
| 20 |
);
|
| 21 |
});
|
| 22 |
|
| 23 |
+
it('rejects missing bearer tokens without falling back to access-code auth', () => {
|
| 24 |
assert.throws(
|
| 25 |
+
() => assertAgentAuthorized(new Headers(), { AGENT_API_TOKEN: 'secret-token', APP_PASSWORD: 'access-code' }),
|
| 26 |
(error) => error instanceof AgentApiError && error.code === 'unauthorized'
|
| 27 |
);
|
| 28 |
});
|
| 29 |
|
| 30 |
+
it('trims APP_PASSWORD before verifying access-code hashes', () => {
|
| 31 |
const passwordHash = crypto.createHash('sha256').update(PAGE_PASSWORD_FIXTURE).digest('hex');
|
| 32 |
assert.doesNotThrow(() =>
|
| 33 |
assertAgentAuthorized(new Headers({ 'X-App-Password-Hash': passwordHash }), {
|
src/lib/agent-auth.ts
CHANGED
|
@@ -25,7 +25,7 @@ export function assertAgentAuthorized(headers: Headers, env: Record<string, stri
|
|
| 25 |
if (!passwordHash || !verifyPasswordHash(passwordHash, appPassword)) {
|
| 26 |
throw new AgentApiError({
|
| 27 |
code: 'unauthorized',
|
| 28 |
-
message: '未授权:
|
| 29 |
status: 401,
|
| 30 |
retryable: false
|
| 31 |
});
|
|
|
|
| 25 |
if (!passwordHash || !verifyPasswordHash(passwordHash, appPassword)) {
|
| 26 |
throw new AgentApiError({
|
| 27 |
code: 'unauthorized',
|
| 28 |
+
message: '未授权:访问码哈希无效或缺失。',
|
| 29 |
status: 401,
|
| 30 |
retryable: false
|
| 31 |
});
|
src/lib/agent-image-service.test.ts
CHANGED
|
@@ -139,9 +139,15 @@ function createReplayStore(artifacts: AgentArtifactRecord[]): AgentStateStore {
|
|
| 139 |
async beginRequest() {
|
| 140 |
throw new Error('not implemented');
|
| 141 |
},
|
|
|
|
|
|
|
|
|
|
| 142 |
async saveArtifacts() {},
|
| 143 |
async completeRequest() {},
|
| 144 |
async failRequest() {},
|
|
|
|
|
|
|
|
|
|
| 145 |
async getArtifact() {
|
| 146 |
return undefined;
|
| 147 |
},
|
|
|
|
| 139 |
async beginRequest() {
|
| 140 |
throw new Error('not implemented');
|
| 141 |
},
|
| 142 |
+
async refreshRequestLease() {
|
| 143 |
+
return false;
|
| 144 |
+
},
|
| 145 |
async saveArtifacts() {},
|
| 146 |
async completeRequest() {},
|
| 147 |
async failRequest() {},
|
| 148 |
+
async getRequest() {
|
| 149 |
+
return undefined;
|
| 150 |
+
},
|
| 151 |
async getArtifact() {
|
| 152 |
return undefined;
|
| 153 |
},
|