Spaces:
Running
Running
gpt-image-playground deploy commited on
Commit ·
a2c232b
1
Parent(s): e5a9556
Deploy 4770498 to Docker Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +6 -0
- Dockerfile +1 -0
- README.md +16 -3
- docs/deployment/huggingface-space-free.md +6 -0
- package-lock.json +190 -197
- package.json +6 -3
- public/hf-space-deploy-marker.json +3 -3
- scripts/agent-skill-scripts.test.mjs +2 -0
- scripts/brace-expansion-compat.test.mjs +35 -0
- scripts/channel-capability-matrix.test.mjs +562 -0
- scripts/dependency-installation.mjs +11 -6
- scripts/dependency-installation.test.mjs +62 -2
- scripts/docker-build-context.test.mjs +1 -0
- scripts/env-summary.mjs +2 -1
- scripts/env-summary.test.mjs +10 -0
- scripts/smoke-image-upstream-compat.mjs +1 -0
- scripts/smoke-image-upstream-local-final-gate.mjs +1 -0
- scripts/smoke-image-upstream-real.test.mjs +1 -0
- skills/gpt-image-playground-agent/SKILL.md +2 -1
- skills/gpt-image-playground-agent/agents/openai.yaml +2 -2
- skills/gpt-image-playground-agent/references/api.md +15 -0
- skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs +367 -0
- skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs +323 -0
- src/app/api/agent/agent-routes.test.ts +38 -0
- src/app/api/agent/capabilities/route.ts +2 -1
- src/app/api/agent/diagnostics/channel-health/route.test.ts +3 -0
- src/app/api/agent/diagnostics/channel-health/route.ts +1 -0
- src/app/api/deploy-marker/route.ts +1 -1
- src/app/api/images/route-defaults.test.ts +57 -0
- src/app/api/images/route-test-helpers.ts +70 -0
- src/app/api/images/route-test-setup.ts +1 -0
- src/app/api/images/route.ts +7 -0
- src/app/api/runtime-capabilities/route.test.ts +16 -1
- src/app/api/runtime-capabilities/route.ts +4 -0
- src/lib/agent-api-contracts.test.ts +39 -1
- src/lib/agent-api-contracts.ts +4 -1
- src/lib/agent-image-service.ts +13 -0
- src/lib/agent-openapi.ts +28 -2
- src/lib/channel-health-prober.ts +7 -2
- src/lib/channel-health-snapshot.test.ts +1 -0
- src/lib/channel-router.test.ts +61 -0
- src/lib/channel-router.ts +31 -5
- src/lib/image-route-mode-handlers.ts +6 -0
- src/lib/image-route-support.ts +3 -0
- src/lib/image-service.ts +2 -0
- src/lib/image-stream-collector.ts +2 -0
- src/lib/image-stream-service.ts +6 -0
- src/lib/image-url-result.test.ts +29 -0
- src/lib/image-url-result.ts +14 -7
- src/lib/images-api-stream.ts +18 -13
.env.example
CHANGED
|
@@ -10,6 +10,10 @@ OPENAI_API_KEY=
|
|
| 10 |
# 示例:https://api.openai.com/v1
|
| 11 |
OPENAI_API_BASE_URL=
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
# 可选:服务端多渠道多 key 配置。配置任意 OPENAI_CHANNEL_N_* 后,会优先于 OPENAI_API_KEY。
|
| 14 |
# 页面右上角“API 设置”里手动填写的 API Key/API URL 仍然拥有最高优先级。
|
| 15 |
#
|
|
@@ -41,6 +45,7 @@ OPENAI_API_BASE_URL=
|
|
| 41 |
# 单渠道配置优先于 OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY,且不会扩大 REQUEST_MODES 白名单。
|
| 42 |
# 未配置时默认顺序:images-non-stream、images-sse、responses-non-stream、responses-sse。
|
| 43 |
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
|
|
|
|
| 44 |
# - API Key 本身不要包含逗号。
|
| 45 |
#
|
| 46 |
# 示例:
|
|
@@ -50,6 +55,7 @@ OPENAI_API_BASE_URL=
|
|
| 50 |
# OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse
|
| 51 |
# OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse
|
| 52 |
# OPENAI_CHANNEL_1_FAILURE_COOLDOWN_MS=30000
|
|
|
|
| 53 |
# OPENAI_CHANNEL_1_USER_AGENT=gpt-image-playground/customer
|
| 54 |
# OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON={"X-Custom-Client":"customer"}
|
| 55 |
#
|
|
|
|
| 10 |
# 示例:https://api.openai.com/v1
|
| 11 |
OPENAI_API_BASE_URL=
|
| 12 |
|
| 13 |
+
# 可选:服务端到图片上游的全局 HTTP(S) 代理。仅影响服务端出站请求,不影响浏览器访问本服务。
|
| 14 |
+
# 仅支持无认证、无路径、无查询参数和无片段的 http:// 或 https:// 根代理地址;不支持 SOCKS。
|
| 15 |
+
# OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 16 |
+
|
| 17 |
# 可选:服务端多渠道多 key 配置。配置任意 OPENAI_CHANNEL_N_* 后,会优先于 OPENAI_API_KEY。
|
| 18 |
# 页面右上角“API 设置”里手动填写的 API Key/API URL 仍然拥有最高优先级。
|
| 19 |
#
|
|
|
|
| 45 |
# 单渠道配置优先于 OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY,且不会扩大 REQUEST_MODES 白名单。
|
| 46 |
# 未配置时默认顺序:images-non-stream、images-sse、responses-non-stream、responses-sse。
|
| 47 |
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
|
| 48 |
+
# - PROXY_URL 可选,覆盖 OPENAI_UPSTREAM_PROXY_URL,仅用于该渠道的服务端上游请求。
|
| 49 |
# - API Key 本身不要包含逗号。
|
| 50 |
#
|
| 51 |
# 示例:
|
|
|
|
| 55 |
# OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse
|
| 56 |
# OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse
|
| 57 |
# OPENAI_CHANNEL_1_FAILURE_COOLDOWN_MS=30000
|
| 58 |
+
# OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080
|
| 59 |
# OPENAI_CHANNEL_1_USER_AGENT=gpt-image-playground/customer
|
| 60 |
# OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON={"X-Custom-Client":"customer"}
|
| 61 |
#
|
Dockerfile
CHANGED
|
@@ -3,6 +3,7 @@ WORKDIR /app
|
|
| 3 |
RUN apk add --no-cache python3 make g++ pkgconfig
|
| 4 |
COPY package.json package-lock.json ./
|
| 5 |
COPY scripts/check-install-script-policy.mjs scripts/dependency-installation.mjs scripts/npm-install-policy.mjs scripts/node-gyp-local-headers.cjs ./scripts/
|
|
|
|
| 6 |
ENV NODE_OPTIONS=--require=/app/scripts/node-gyp-local-headers.cjs
|
| 7 |
RUN npm run install-scripts:check && npm run npm-install-policy:check && npm ci --strict-allow-scripts && npm run dependencies:check
|
| 8 |
ENV NODE_OPTIONS=
|
|
|
|
| 3 |
RUN apk add --no-cache python3 make g++ pkgconfig
|
| 4 |
COPY package.json package-lock.json ./
|
| 5 |
COPY scripts/check-install-script-policy.mjs scripts/dependency-installation.mjs scripts/npm-install-policy.mjs scripts/node-gyp-local-headers.cjs ./scripts/
|
| 6 |
+
COPY vendor/brace-expansion-compat ./vendor/brace-expansion-compat
|
| 7 |
ENV NODE_OPTIONS=--require=/app/scripts/node-gyp-local-headers.cjs
|
| 8 |
RUN npm run install-scripts:check && npm run npm-install-policy:check && npm ci --strict-allow-scripts && npm run dependencies:check
|
| 9 |
ENV NODE_OPTIONS=
|
README.md
CHANGED
|
@@ -12,7 +12,7 @@ app_port: 4783
|
|
| 12 |
本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
|
| 13 |
|
| 14 |
<p align="center">
|
| 15 |
-
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/
|
| 16 |
</p>
|
| 17 |
|
| 18 |
## 快速开始
|
|
@@ -83,13 +83,13 @@ start-windows.bat
|
|
| 83 |
遮罩编辑示例:
|
| 84 |
|
| 85 |
<p align="center">
|
| 86 |
-
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/
|
| 87 |
</p>
|
| 88 |
|
| 89 |
历史与费用示例:
|
| 90 |
|
| 91 |
<p align="center">
|
| 92 |
-
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/
|
| 93 |
</p>
|
| 94 |
|
| 95 |
## 配置
|
|
@@ -99,6 +99,7 @@ start-windows.bat
|
|
| 99 |
| 场景 | 变量 | 说明 |
|
| 100 |
| ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| 101 |
| 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
|
|
|
|
| 102 |
| 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
|
| 103 |
| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后 `/api/agent/*` 需要 Bearer token。 |
|
| 104 |
| Agent 公开地址 | `AGENT_PUBLIC_BASE_URL` | OpenAPI `servers[0].url` 和 Agent artifact 分享外链使用的公网 base URL。 |
|
|
@@ -137,6 +138,18 @@ OPENAI_CHANNEL_3_API_KEYS=your-matsca-key
|
|
| 137 |
OPENAI_CHANNEL_3_UPSTREAM_PROFILE=matsca
|
| 138 |
```
|
| 139 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
优先级:
|
| 141 |
|
| 142 |
```text
|
|
|
|
| 12 |
本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
|
| 13 |
|
| 14 |
<p align="center">
|
| 15 |
+
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/4770498acf64bbcda373c6a220f5b5781cef6136/readme-images/interface.jpg?v=20260608-07b596b" alt="GPT Image Playground 界面" width="900"/>
|
| 16 |
</p>
|
| 17 |
|
| 18 |
## 快速开始
|
|
|
|
| 83 |
遮罩编辑示例:
|
| 84 |
|
| 85 |
<p align="center">
|
| 86 |
+
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/4770498acf64bbcda373c6a220f5b5781cef6136/readme-images/mask-creation.jpg?v=20260608-07b596b" alt="遮罩创建" width="900"/>
|
| 87 |
</p>
|
| 88 |
|
| 89 |
历史与费用示例:
|
| 90 |
|
| 91 |
<p align="center">
|
| 92 |
+
<img src="https://raw.githubusercontent.com/MisonL/gpt-image-playground-customer/4770498acf64bbcda373c6a220f5b5781cef6136/readme-images/history.jpg?v=20260608-07b596b" alt="历史面板" width="900"/>
|
| 93 |
</p>
|
| 94 |
|
| 95 |
## 配置
|
|
|
|
| 99 |
| 场景 | 变量 | 说明 |
|
| 100 |
| ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| 101 |
| 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
|
| 102 |
+
| 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 可选。只用于服务端到图片上游的出站请求;渠道级地址优先于全局地址。仅接受无认证、无路径、无查询参数和无片段的 `http://` / `https://` 根代理地址,不支持 SOCKS。运行态和 Agent 诊断只公开是否启用及协议,不公开代理主机或端口。 |
|
| 103 |
| 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
|
| 104 |
| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后 `/api/agent/*` 需要 Bearer token。 |
|
| 105 |
| Agent 公开地址 | `AGENT_PUBLIC_BASE_URL` | OpenAPI `servers[0].url` 和 Agent artifact 分享外链使用的公网 base URL。 |
|
|
|
|
| 138 |
OPENAI_CHANNEL_3_UPSTREAM_PROFILE=matsca
|
| 139 |
```
|
| 140 |
|
| 141 |
+
服务端上游代理可按全局或渠道单独配置:
|
| 142 |
+
|
| 143 |
+
```dotenv
|
| 144 |
+
# 所有未单独覆盖的上游渠道使用此代理。
|
| 145 |
+
OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 146 |
+
|
| 147 |
+
# 仅覆盖渠道 2,优先级高于全局代理。
|
| 148 |
+
OPENAI_CHANNEL_2_PROXY_URL=https://channel-proxy.internal:8443
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
代理仅作用于服务端到上游的 OpenAI/兼容 API、上游 SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志请求,不改变浏览器到本服务的连接,也不使用浏览器系统代理。代理 URL 只能是无认证的 `http://` 或 `https://` 根地址,不能包含 SOCKS 协议、用户名密码、路径、查询参数或片段。修改代理环境变量后必须重启服务或重新部署。`GET /api/runtime-capabilities`、Agent capabilities 和渠道健康诊断只显示 `configured` 与 `protocol`,不会返回代理主机或端口。
|
| 152 |
+
|
| 153 |
优先级:
|
| 154 |
|
| 155 |
```text
|
docs/deployment/huggingface-space-free.md
CHANGED
|
@@ -157,12 +157,16 @@ AGENT_PUBLIC_BASE_URL=https://<user>-<space>.hf.space
|
|
| 157 |
```dotenv
|
| 158 |
OPENAI_API_KEY=<your-api-key>
|
| 159 |
OPENAI_API_BASE_URL=https://api.openai.com/v1
|
|
|
|
|
|
|
| 160 |
APP_PASSWORD=<page-access-code>
|
| 161 |
AGENT_API_TOKEN=<long-random-agent-token>
|
| 162 |
```
|
| 163 |
|
| 164 |
`OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` 结尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。
|
| 165 |
|
|
|
|
|
|
|
| 166 |
公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
|
| 167 |
|
| 168 |
如果要把这个 Space 当成客户可见的公网服务,`npm run doctor:hf-space` 的 `remote-secrets` 必须通过,且应同时看到 `APP_PASSWORD` 和 `AGENT_API_TOKEN` 已配置。没有这两个值时,只适合本地或受控内网试用,不适合直接给客户公开。
|
|
@@ -174,6 +178,8 @@ OPENAI_ROUTING_STRATEGY=round_robin
|
|
| 174 |
OPENAI_CHANNEL_1_ID=official
|
| 175 |
OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
|
| 176 |
OPENAI_CHANNEL_1_API_KEYS=<key-a>,<key-b>
|
|
|
|
|
|
|
| 177 |
```
|
| 178 |
|
| 179 |
## 手机网页使用
|
|
|
|
| 157 |
```dotenv
|
| 158 |
OPENAI_API_KEY=<your-api-key>
|
| 159 |
OPENAI_API_BASE_URL=https://api.openai.com/v1
|
| 160 |
+
# 可选:仅服务端到上游的无认证 HTTP(S) 代理。
|
| 161 |
+
OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
|
| 162 |
APP_PASSWORD=<page-access-code>
|
| 163 |
AGENT_API_TOKEN=<long-random-agent-token>
|
| 164 |
```
|
| 165 |
|
| 166 |
`OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` 结尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。
|
| 167 |
|
| 168 |
+
`OPENAI_UPSTREAM_PROXY_URL` 只影响 Space 服务端到上游 API 的出站连接,不影响用户浏览器访问 Space。它仅接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS。多渠道部署可用 `OPENAI_CHANNEL_N_PROXY_URL` 覆盖全局代理,渠道级值优先。代理地址即使不含凭据也建议作为 Space Secret 管理;修改后需要重新启动或重新部署 Space。运行态和 Agent 诊断只公开是否配置及协议,不公开主机或端口。
|
| 169 |
+
|
| 170 |
公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
|
| 171 |
|
| 172 |
如果要把这个 Space 当成客户可见的公网服务,`npm run doctor:hf-space` 的 `remote-secrets` 必须通过,且应同时看到 `APP_PASSWORD` 和 `AGENT_API_TOKEN` 已配置。没有这两个值时,只适合本地或受控内网试用,不适合直接给客户公开。
|
|
|
|
| 178 |
OPENAI_CHANNEL_1_ID=official
|
| 179 |
OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
|
| 180 |
OPENAI_CHANNEL_1_API_KEYS=<key-a>,<key-b>
|
| 181 |
+
# 可选:仅覆盖此渠道的全局上游代理。
|
| 182 |
+
OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080
|
| 183 |
```
|
| 184 |
|
| 185 |
## 手机网页使用
|
package-lock.json
CHANGED
|
@@ -32,11 +32,12 @@
|
|
| 32 |
"next-themes": "^0.4.6",
|
| 33 |
"openai": "^6.34.0",
|
| 34 |
"pg": "^8.20.0",
|
| 35 |
-
"postcss": "^8.5.
|
| 36 |
"react": "^19.0.0",
|
| 37 |
"react-dom": "^19.0.0",
|
| 38 |
"sharp": "^0.35.3",
|
| 39 |
-
"tailwind-merge": "^3.2.0"
|
|
|
|
| 40 |
},
|
| 41 |
"devDependencies": {
|
| 42 |
"@eslint/eslintrc": "^3",
|
|
@@ -48,6 +49,7 @@
|
|
| 48 |
"@types/pg": "^8.20.0",
|
| 49 |
"@types/react": "^19",
|
| 50 |
"@types/react-dom": "^19",
|
|
|
|
| 51 |
"eslint": "^9",
|
| 52 |
"eslint-config-next": "^16.2.10",
|
| 53 |
"happy-dom": "^20.10.6",
|
|
@@ -3131,6 +3133,72 @@
|
|
| 3131 |
"node": ">=14.0.0"
|
| 3132 |
}
|
| 3133 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3134 |
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
| 3135 |
"version": "4.3.3",
|
| 3136 |
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
|
@@ -3220,6 +3288,22 @@
|
|
| 3220 |
}
|
| 3221 |
}
|
| 3222 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3223 |
"node_modules/@tybys/wasm-util": {
|
| 3224 |
"version": "0.9.0",
|
| 3225 |
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
|
@@ -3514,29 +3598,6 @@
|
|
| 3514 |
"typescript": ">=4.8.4 <6.1.0"
|
| 3515 |
}
|
| 3516 |
},
|
| 3517 |
-
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
| 3518 |
-
"version": "4.0.4",
|
| 3519 |
-
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
| 3520 |
-
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
| 3521 |
-
"dev": true,
|
| 3522 |
-
"license": "MIT",
|
| 3523 |
-
"engines": {
|
| 3524 |
-
"node": "18 || 20 || >=22"
|
| 3525 |
-
}
|
| 3526 |
-
},
|
| 3527 |
-
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
| 3528 |
-
"version": "5.0.7",
|
| 3529 |
-
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
| 3530 |
-
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
| 3531 |
-
"dev": true,
|
| 3532 |
-
"license": "MIT",
|
| 3533 |
-
"dependencies": {
|
| 3534 |
-
"balanced-match": "^4.0.2"
|
| 3535 |
-
},
|
| 3536 |
-
"engines": {
|
| 3537 |
-
"node": "18 || 20 || >=22"
|
| 3538 |
-
}
|
| 3539 |
-
},
|
| 3540 |
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
| 3541 |
"version": "10.2.5",
|
| 3542 |
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
@@ -4134,11 +4195,14 @@
|
|
| 4134 |
}
|
| 4135 |
},
|
| 4136 |
"node_modules/balanced-match": {
|
| 4137 |
-
"version": "
|
| 4138 |
-
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-
|
| 4139 |
-
"integrity": "sha512-
|
| 4140 |
"dev": true,
|
| 4141 |
-
"license": "MIT"
|
|
|
|
|
|
|
|
|
|
| 4142 |
},
|
| 4143 |
"node_modules/baseline-browser-mapping": {
|
| 4144 |
"version": "2.10.43",
|
|
@@ -4166,14 +4230,21 @@
|
|
| 4166 |
}
|
| 4167 |
},
|
| 4168 |
"node_modules/brace-expansion": {
|
| 4169 |
-
"
|
| 4170 |
-
"
|
| 4171 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4172 |
"dev": true,
|
| 4173 |
"license": "MIT",
|
| 4174 |
"dependencies": {
|
| 4175 |
-
"balanced-match": "^
|
| 4176 |
-
|
|
|
|
|
|
|
| 4177 |
}
|
| 4178 |
},
|
| 4179 |
"node_modules/braces": {
|
|
@@ -4380,13 +4451,6 @@
|
|
| 4380 |
"dev": true,
|
| 4381 |
"license": "MIT"
|
| 4382 |
},
|
| 4383 |
-
"node_modules/concat-map": {
|
| 4384 |
-
"version": "0.0.1",
|
| 4385 |
-
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
| 4386 |
-
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
| 4387 |
-
"dev": true,
|
| 4388 |
-
"license": "MIT"
|
| 4389 |
-
},
|
| 4390 |
"node_modules/convert-source-map": {
|
| 4391 |
"version": "2.0.0",
|
| 4392 |
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
|
@@ -6471,6 +6535,27 @@
|
|
| 6471 |
"lightningcss-win32-x64-msvc": "1.32.0"
|
| 6472 |
}
|
| 6473 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6474 |
"node_modules/lightningcss-darwin-arm64": {
|
| 6475 |
"version": "1.32.0",
|
| 6476 |
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
|
@@ -6697,6 +6782,13 @@
|
|
| 6697 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 6698 |
}
|
| 6699 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6700 |
"node_modules/lodash.merge": {
|
| 6701 |
"version": "4.6.2",
|
| 6702 |
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
|
@@ -6736,6 +6828,16 @@
|
|
| 6736 |
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 6737 |
}
|
| 6738 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6739 |
"node_modules/math-intrinsics": {
|
| 6740 |
"version": "1.1.0",
|
| 6741 |
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
@@ -6824,9 +6926,9 @@
|
|
| 6824 |
"license": "MIT"
|
| 6825 |
},
|
| 6826 |
"node_modules/nanoid": {
|
| 6827 |
-
"version": "3.3.
|
| 6828 |
-
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.
|
| 6829 |
-
"integrity": "sha512-
|
| 6830 |
"funding": [
|
| 6831 |
{
|
| 6832 |
"type": "github",
|
|
@@ -6927,6 +7029,15 @@
|
|
| 6927 |
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
| 6928 |
}
|
| 6929 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6930 |
"node_modules/node-releases": {
|
| 6931 |
"version": "2.0.51",
|
| 6932 |
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
|
@@ -7162,6 +7273,23 @@
|
|
| 7162 |
"node": ">=6"
|
| 7163 |
}
|
| 7164 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7165 |
"node_modules/path-exists": {
|
| 7166 |
"version": "4.0.0",
|
| 7167 |
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
|
@@ -7308,9 +7436,9 @@
|
|
| 7308 |
}
|
| 7309 |
},
|
| 7310 |
"node_modules/postcss": {
|
| 7311 |
-
"version": "8.5.
|
| 7312 |
-
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.
|
| 7313 |
-
"integrity": "sha512-
|
| 7314 |
"funding": [
|
| 7315 |
{
|
| 7316 |
"type": "opencollective",
|
|
@@ -7327,7 +7455,7 @@
|
|
| 7327 |
],
|
| 7328 |
"license": "MIT",
|
| 7329 |
"dependencies": {
|
| 7330 |
-
"nanoid": "^3.3.
|
| 7331 |
"picocolors": "^1.1.1",
|
| 7332 |
"source-map-js": "^1.2.1"
|
| 7333 |
},
|
|
@@ -8534,6 +8662,15 @@
|
|
| 8534 |
"url": "https://github.com/sponsors/ljharb"
|
| 8535 |
}
|
| 8536 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8537 |
"node_modules/undici-types": {
|
| 8538 |
"version": "6.19.8",
|
| 8539 |
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
|
@@ -8856,160 +8993,16 @@
|
|
| 8856 |
"zod": "^3.25.0 || ^4.0.0"
|
| 8857 |
}
|
| 8858 |
},
|
| 8859 |
-
"
|
| 8860 |
-
"
|
| 8861 |
-
"
|
| 8862 |
-
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
| 8863 |
"dev": true,
|
| 8864 |
"license": "MIT",
|
| 8865 |
"dependencies": {
|
| 8866 |
-
"
|
| 8867 |
-
}
|
| 8868 |
-
},
|
| 8869 |
-
"node_modules/@trivago/prettier-plugin-sort-imports/node_modules/minimatch": {
|
| 8870 |
-
"version": "9.0.9",
|
| 8871 |
-
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
| 8872 |
-
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
| 8873 |
-
"dev": true,
|
| 8874 |
-
"license": "ISC",
|
| 8875 |
-
"dependencies": {
|
| 8876 |
-
"brace-expansion": "^2.0.2"
|
| 8877 |
},
|
| 8878 |
"engines": {
|
| 8879 |
-
"node": ">=
|
| 8880 |
-
},
|
| 8881 |
-
"funding": {
|
| 8882 |
-
"url": "https://github.com/sponsors/isaacs"
|
| 8883 |
-
}
|
| 8884 |
-
},
|
| 8885 |
-
"node_modules/lodash-es": {
|
| 8886 |
-
"version": "4.18.1",
|
| 8887 |
-
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
| 8888 |
-
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
| 8889 |
-
"dev": true,
|
| 8890 |
-
"license": "MIT"
|
| 8891 |
-
},
|
| 8892 |
-
"node_modules/parse-imports-exports": {
|
| 8893 |
-
"version": "0.2.4",
|
| 8894 |
-
"resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
|
| 8895 |
-
"integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==",
|
| 8896 |
-
"dev": true,
|
| 8897 |
-
"license": "MIT",
|
| 8898 |
-
"dependencies": {
|
| 8899 |
-
"parse-statements": "1.0.11"
|
| 8900 |
-
}
|
| 8901 |
-
},
|
| 8902 |
-
"node_modules/parse-statements": {
|
| 8903 |
-
"version": "1.0.11",
|
| 8904 |
-
"resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz",
|
| 8905 |
-
"integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==",
|
| 8906 |
-
"dev": true,
|
| 8907 |
-
"license": "MIT"
|
| 8908 |
-
},
|
| 8909 |
-
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
| 8910 |
-
"version": "1.11.1",
|
| 8911 |
-
"dev": true,
|
| 8912 |
-
"inBundle": true,
|
| 8913 |
-
"license": "MIT",
|
| 8914 |
-
"optional": true,
|
| 8915 |
-
"dependencies": {
|
| 8916 |
-
"@emnapi/wasi-threads": "1.2.2",
|
| 8917 |
-
"tslib": "^2.4.0"
|
| 8918 |
-
}
|
| 8919 |
-
},
|
| 8920 |
-
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
| 8921 |
-
"version": "1.11.1",
|
| 8922 |
-
"dev": true,
|
| 8923 |
-
"inBundle": true,
|
| 8924 |
-
"license": "MIT",
|
| 8925 |
-
"optional": true,
|
| 8926 |
-
"dependencies": {
|
| 8927 |
-
"tslib": "^2.4.0"
|
| 8928 |
-
}
|
| 8929 |
-
},
|
| 8930 |
-
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
| 8931 |
-
"version": "1.2.2",
|
| 8932 |
-
"dev": true,
|
| 8933 |
-
"inBundle": true,
|
| 8934 |
-
"license": "MIT",
|
| 8935 |
-
"optional": true,
|
| 8936 |
-
"dependencies": {
|
| 8937 |
-
"tslib": "^2.4.0"
|
| 8938 |
-
}
|
| 8939 |
-
},
|
| 8940 |
-
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
| 8941 |
-
"version": "1.1.4",
|
| 8942 |
-
"dev": true,
|
| 8943 |
-
"inBundle": true,
|
| 8944 |
-
"license": "MIT",
|
| 8945 |
-
"optional": true,
|
| 8946 |
-
"dependencies": {
|
| 8947 |
-
"@tybys/wasm-util": "^0.10.1"
|
| 8948 |
-
},
|
| 8949 |
-
"funding": {
|
| 8950 |
-
"type": "github",
|
| 8951 |
-
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 8952 |
-
},
|
| 8953 |
-
"peerDependencies": {
|
| 8954 |
-
"@emnapi/core": "^1.7.1",
|
| 8955 |
-
"@emnapi/runtime": "^1.7.1"
|
| 8956 |
-
}
|
| 8957 |
-
},
|
| 8958 |
-
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
| 8959 |
-
"version": "0.10.2",
|
| 8960 |
-
"dev": true,
|
| 8961 |
-
"inBundle": true,
|
| 8962 |
-
"license": "MIT",
|
| 8963 |
-
"optional": true,
|
| 8964 |
-
"dependencies": {
|
| 8965 |
-
"tslib": "^2.4.0"
|
| 8966 |
-
}
|
| 8967 |
-
},
|
| 8968 |
-
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
| 8969 |
-
"version": "2.8.1",
|
| 8970 |
-
"dev": true,
|
| 8971 |
-
"inBundle": true,
|
| 8972 |
-
"license": "0BSD",
|
| 8973 |
-
"optional": true
|
| 8974 |
-
},
|
| 8975 |
-
"node_modules/lightningcss-android-arm64": {
|
| 8976 |
-
"version": "1.32.0",
|
| 8977 |
-
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
| 8978 |
-
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
| 8979 |
-
"cpu": [
|
| 8980 |
-
"arm64"
|
| 8981 |
-
],
|
| 8982 |
-
"dev": true,
|
| 8983 |
-
"license": "MPL-2.0",
|
| 8984 |
-
"optional": true,
|
| 8985 |
-
"os": [
|
| 8986 |
-
"android"
|
| 8987 |
-
],
|
| 8988 |
-
"engines": {
|
| 8989 |
-
"node": ">= 12.0.0"
|
| 8990 |
-
},
|
| 8991 |
-
"funding": {
|
| 8992 |
-
"type": "opencollective",
|
| 8993 |
-
"url": "https://opencollective.com/parcel"
|
| 8994 |
-
}
|
| 8995 |
-
},
|
| 8996 |
-
"node_modules/magic-string": {
|
| 8997 |
-
"version": "0.30.21",
|
| 8998 |
-
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
| 8999 |
-
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
| 9000 |
-
"dev": true,
|
| 9001 |
-
"license": "MIT",
|
| 9002 |
-
"dependencies": {
|
| 9003 |
-
"@jridgewell/sourcemap-codec": "^1.5.5"
|
| 9004 |
-
}
|
| 9005 |
-
},
|
| 9006 |
-
"node_modules/node-addon-api": {
|
| 9007 |
-
"version": "8.9.0",
|
| 9008 |
-
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
| 9009 |
-
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
|
| 9010 |
-
"license": "MIT",
|
| 9011 |
-
"engines": {
|
| 9012 |
-
"node": "^18 || ^20 || >= 21"
|
| 9013 |
}
|
| 9014 |
}
|
| 9015 |
}
|
|
|
|
| 32 |
"next-themes": "^0.4.6",
|
| 33 |
"openai": "^6.34.0",
|
| 34 |
"pg": "^8.20.0",
|
| 35 |
+
"postcss": "^8.5.18",
|
| 36 |
"react": "^19.0.0",
|
| 37 |
"react-dom": "^19.0.0",
|
| 38 |
"sharp": "^0.35.3",
|
| 39 |
+
"tailwind-merge": "^3.2.0",
|
| 40 |
+
"undici": "^7.21.0"
|
| 41 |
},
|
| 42 |
"devDependencies": {
|
| 43 |
"@eslint/eslintrc": "^3",
|
|
|
|
| 49 |
"@types/pg": "^8.20.0",
|
| 50 |
"@types/react": "^19",
|
| 51 |
"@types/react-dom": "^19",
|
| 52 |
+
"brace-expansion": "file:vendor/brace-expansion-compat",
|
| 53 |
"eslint": "^9",
|
| 54 |
"eslint-config-next": "^16.2.10",
|
| 55 |
"happy-dom": "^20.10.6",
|
|
|
|
| 3133 |
"node": ">=14.0.0"
|
| 3134 |
}
|
| 3135 |
},
|
| 3136 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
| 3137 |
+
"version": "1.11.1",
|
| 3138 |
+
"dev": true,
|
| 3139 |
+
"inBundle": true,
|
| 3140 |
+
"license": "MIT",
|
| 3141 |
+
"optional": true,
|
| 3142 |
+
"dependencies": {
|
| 3143 |
+
"@emnapi/wasi-threads": "1.2.2",
|
| 3144 |
+
"tslib": "^2.4.0"
|
| 3145 |
+
}
|
| 3146 |
+
},
|
| 3147 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
| 3148 |
+
"version": "1.11.1",
|
| 3149 |
+
"dev": true,
|
| 3150 |
+
"inBundle": true,
|
| 3151 |
+
"license": "MIT",
|
| 3152 |
+
"optional": true,
|
| 3153 |
+
"dependencies": {
|
| 3154 |
+
"tslib": "^2.4.0"
|
| 3155 |
+
}
|
| 3156 |
+
},
|
| 3157 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
| 3158 |
+
"version": "1.2.2",
|
| 3159 |
+
"dev": true,
|
| 3160 |
+
"inBundle": true,
|
| 3161 |
+
"license": "MIT",
|
| 3162 |
+
"optional": true,
|
| 3163 |
+
"dependencies": {
|
| 3164 |
+
"tslib": "^2.4.0"
|
| 3165 |
+
}
|
| 3166 |
+
},
|
| 3167 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
| 3168 |
+
"version": "1.1.4",
|
| 3169 |
+
"dev": true,
|
| 3170 |
+
"inBundle": true,
|
| 3171 |
+
"license": "MIT",
|
| 3172 |
+
"optional": true,
|
| 3173 |
+
"dependencies": {
|
| 3174 |
+
"@tybys/wasm-util": "^0.10.1"
|
| 3175 |
+
},
|
| 3176 |
+
"funding": {
|
| 3177 |
+
"type": "github",
|
| 3178 |
+
"url": "https://github.com/sponsors/Brooooooklyn"
|
| 3179 |
+
},
|
| 3180 |
+
"peerDependencies": {
|
| 3181 |
+
"@emnapi/core": "^1.7.1",
|
| 3182 |
+
"@emnapi/runtime": "^1.7.1"
|
| 3183 |
+
}
|
| 3184 |
+
},
|
| 3185 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
| 3186 |
+
"version": "0.10.2",
|
| 3187 |
+
"dev": true,
|
| 3188 |
+
"inBundle": true,
|
| 3189 |
+
"license": "MIT",
|
| 3190 |
+
"optional": true,
|
| 3191 |
+
"dependencies": {
|
| 3192 |
+
"tslib": "^2.4.0"
|
| 3193 |
+
}
|
| 3194 |
+
},
|
| 3195 |
+
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
| 3196 |
+
"version": "2.8.1",
|
| 3197 |
+
"dev": true,
|
| 3198 |
+
"inBundle": true,
|
| 3199 |
+
"license": "0BSD",
|
| 3200 |
+
"optional": true
|
| 3201 |
+
},
|
| 3202 |
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
| 3203 |
"version": "4.3.3",
|
| 3204 |
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
|
|
|
| 3288 |
}
|
| 3289 |
}
|
| 3290 |
},
|
| 3291 |
+
"node_modules/@trivago/prettier-plugin-sort-imports/node_modules/minimatch": {
|
| 3292 |
+
"version": "9.0.9",
|
| 3293 |
+
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
|
| 3294 |
+
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
|
| 3295 |
+
"dev": true,
|
| 3296 |
+
"license": "ISC",
|
| 3297 |
+
"dependencies": {
|
| 3298 |
+
"brace-expansion": "^2.0.2"
|
| 3299 |
+
},
|
| 3300 |
+
"engines": {
|
| 3301 |
+
"node": ">=16 || 14 >=14.17"
|
| 3302 |
+
},
|
| 3303 |
+
"funding": {
|
| 3304 |
+
"url": "https://github.com/sponsors/isaacs"
|
| 3305 |
+
}
|
| 3306 |
+
},
|
| 3307 |
"node_modules/@tybys/wasm-util": {
|
| 3308 |
"version": "0.9.0",
|
| 3309 |
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
|
|
|
|
| 3598 |
"typescript": ">=4.8.4 <6.1.0"
|
| 3599 |
}
|
| 3600 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3601 |
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
| 3602 |
"version": "10.2.5",
|
| 3603 |
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
|
|
|
| 4195 |
}
|
| 4196 |
},
|
| 4197 |
"node_modules/balanced-match": {
|
| 4198 |
+
"version": "4.0.4",
|
| 4199 |
+
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
| 4200 |
+
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
| 4201 |
"dev": true,
|
| 4202 |
+
"license": "MIT",
|
| 4203 |
+
"engines": {
|
| 4204 |
+
"node": "18 || 20 || >=22"
|
| 4205 |
+
}
|
| 4206 |
},
|
| 4207 |
"node_modules/baseline-browser-mapping": {
|
| 4208 |
"version": "2.10.43",
|
|
|
|
| 4230 |
}
|
| 4231 |
},
|
| 4232 |
"node_modules/brace-expansion": {
|
| 4233 |
+
"resolved": "vendor/brace-expansion-compat",
|
| 4234 |
+
"link": true
|
| 4235 |
+
},
|
| 4236 |
+
"node_modules/brace-expansion-modern": {
|
| 4237 |
+
"name": "brace-expansion",
|
| 4238 |
+
"version": "5.0.8",
|
| 4239 |
+
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
| 4240 |
+
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
| 4241 |
"dev": true,
|
| 4242 |
"license": "MIT",
|
| 4243 |
"dependencies": {
|
| 4244 |
+
"balanced-match": "^4.0.2"
|
| 4245 |
+
},
|
| 4246 |
+
"engines": {
|
| 4247 |
+
"node": "20 || >=22"
|
| 4248 |
}
|
| 4249 |
},
|
| 4250 |
"node_modules/braces": {
|
|
|
|
| 4451 |
"dev": true,
|
| 4452 |
"license": "MIT"
|
| 4453 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4454 |
"node_modules/convert-source-map": {
|
| 4455 |
"version": "2.0.0",
|
| 4456 |
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
|
|
|
| 6535 |
"lightningcss-win32-x64-msvc": "1.32.0"
|
| 6536 |
}
|
| 6537 |
},
|
| 6538 |
+
"node_modules/lightningcss-android-arm64": {
|
| 6539 |
+
"version": "1.32.0",
|
| 6540 |
+
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
| 6541 |
+
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
| 6542 |
+
"cpu": [
|
| 6543 |
+
"arm64"
|
| 6544 |
+
],
|
| 6545 |
+
"dev": true,
|
| 6546 |
+
"license": "MPL-2.0",
|
| 6547 |
+
"optional": true,
|
| 6548 |
+
"os": [
|
| 6549 |
+
"android"
|
| 6550 |
+
],
|
| 6551 |
+
"engines": {
|
| 6552 |
+
"node": ">= 12.0.0"
|
| 6553 |
+
},
|
| 6554 |
+
"funding": {
|
| 6555 |
+
"type": "opencollective",
|
| 6556 |
+
"url": "https://opencollective.com/parcel"
|
| 6557 |
+
}
|
| 6558 |
+
},
|
| 6559 |
"node_modules/lightningcss-darwin-arm64": {
|
| 6560 |
"version": "1.32.0",
|
| 6561 |
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
|
|
|
| 6782 |
"url": "https://github.com/sponsors/sindresorhus"
|
| 6783 |
}
|
| 6784 |
},
|
| 6785 |
+
"node_modules/lodash-es": {
|
| 6786 |
+
"version": "4.18.1",
|
| 6787 |
+
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
| 6788 |
+
"integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
|
| 6789 |
+
"dev": true,
|
| 6790 |
+
"license": "MIT"
|
| 6791 |
+
},
|
| 6792 |
"node_modules/lodash.merge": {
|
| 6793 |
"version": "4.6.2",
|
| 6794 |
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
|
|
|
| 6828 |
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 6829 |
}
|
| 6830 |
},
|
| 6831 |
+
"node_modules/magic-string": {
|
| 6832 |
+
"version": "0.30.21",
|
| 6833 |
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
| 6834 |
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
| 6835 |
+
"dev": true,
|
| 6836 |
+
"license": "MIT",
|
| 6837 |
+
"dependencies": {
|
| 6838 |
+
"@jridgewell/sourcemap-codec": "^1.5.5"
|
| 6839 |
+
}
|
| 6840 |
+
},
|
| 6841 |
"node_modules/math-intrinsics": {
|
| 6842 |
"version": "1.1.0",
|
| 6843 |
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
|
|
|
| 6926 |
"license": "MIT"
|
| 6927 |
},
|
| 6928 |
"node_modules/nanoid": {
|
| 6929 |
+
"version": "3.3.16",
|
| 6930 |
+
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
| 6931 |
+
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
| 6932 |
"funding": [
|
| 6933 |
{
|
| 6934 |
"type": "github",
|
|
|
|
| 7029 |
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
|
| 7030 |
}
|
| 7031 |
},
|
| 7032 |
+
"node_modules/node-addon-api": {
|
| 7033 |
+
"version": "8.9.0",
|
| 7034 |
+
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
| 7035 |
+
"integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
|
| 7036 |
+
"license": "MIT",
|
| 7037 |
+
"engines": {
|
| 7038 |
+
"node": "^18 || ^20 || >= 21"
|
| 7039 |
+
}
|
| 7040 |
+
},
|
| 7041 |
"node_modules/node-releases": {
|
| 7042 |
"version": "2.0.51",
|
| 7043 |
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
|
|
|
| 7273 |
"node": ">=6"
|
| 7274 |
}
|
| 7275 |
},
|
| 7276 |
+
"node_modules/parse-imports-exports": {
|
| 7277 |
+
"version": "0.2.4",
|
| 7278 |
+
"resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
|
| 7279 |
+
"integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==",
|
| 7280 |
+
"dev": true,
|
| 7281 |
+
"license": "MIT",
|
| 7282 |
+
"dependencies": {
|
| 7283 |
+
"parse-statements": "1.0.11"
|
| 7284 |
+
}
|
| 7285 |
+
},
|
| 7286 |
+
"node_modules/parse-statements": {
|
| 7287 |
+
"version": "1.0.11",
|
| 7288 |
+
"resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz",
|
| 7289 |
+
"integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==",
|
| 7290 |
+
"dev": true,
|
| 7291 |
+
"license": "MIT"
|
| 7292 |
+
},
|
| 7293 |
"node_modules/path-exists": {
|
| 7294 |
"version": "4.0.0",
|
| 7295 |
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
|
|
|
| 7436 |
}
|
| 7437 |
},
|
| 7438 |
"node_modules/postcss": {
|
| 7439 |
+
"version": "8.5.23",
|
| 7440 |
+
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
|
| 7441 |
+
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
|
| 7442 |
"funding": [
|
| 7443 |
{
|
| 7444 |
"type": "opencollective",
|
|
|
|
| 7455 |
],
|
| 7456 |
"license": "MIT",
|
| 7457 |
"dependencies": {
|
| 7458 |
+
"nanoid": "^3.3.16",
|
| 7459 |
"picocolors": "^1.1.1",
|
| 7460 |
"source-map-js": "^1.2.1"
|
| 7461 |
},
|
|
|
|
| 8662 |
"url": "https://github.com/sponsors/ljharb"
|
| 8663 |
}
|
| 8664 |
},
|
| 8665 |
+
"node_modules/undici": {
|
| 8666 |
+
"version": "7.29.0",
|
| 8667 |
+
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
| 8668 |
+
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
| 8669 |
+
"license": "MIT",
|
| 8670 |
+
"engines": {
|
| 8671 |
+
"node": ">=20.18.1"
|
| 8672 |
+
}
|
| 8673 |
+
},
|
| 8674 |
"node_modules/undici-types": {
|
| 8675 |
"version": "6.19.8",
|
| 8676 |
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
|
|
|
|
| 8993 |
"zod": "^3.25.0 || ^4.0.0"
|
| 8994 |
}
|
| 8995 |
},
|
| 8996 |
+
"vendor/brace-expansion-compat": {
|
| 8997 |
+
"name": "brace-expansion",
|
| 8998 |
+
"version": "5.0.8",
|
|
|
|
| 8999 |
"dev": true,
|
| 9000 |
"license": "MIT",
|
| 9001 |
"dependencies": {
|
| 9002 |
+
"brace-expansion-modern": "npm:brace-expansion@5.0.8"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9003 |
},
|
| 9004 |
"engines": {
|
| 9005 |
+
"node": ">=20"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9006 |
}
|
| 9007 |
}
|
| 9008 |
}
|
package.json
CHANGED
|
@@ -67,11 +67,12 @@
|
|
| 67 |
"next-themes": "^0.4.6",
|
| 68 |
"openai": "^6.34.0",
|
| 69 |
"pg": "^8.20.0",
|
| 70 |
-
"postcss": "^8.5.
|
| 71 |
"react": "^19.0.0",
|
| 72 |
"react-dom": "^19.0.0",
|
| 73 |
"sharp": "^0.35.3",
|
| 74 |
-
"tailwind-merge": "^3.2.0"
|
|
|
|
| 75 |
},
|
| 76 |
"devDependencies": {
|
| 77 |
"@eslint/eslintrc": "^3",
|
|
@@ -83,6 +84,7 @@
|
|
| 83 |
"@types/pg": "^8.20.0",
|
| 84 |
"@types/react": "^19",
|
| 85 |
"@types/react-dom": "^19",
|
|
|
|
| 86 |
"eslint": "^9",
|
| 87 |
"eslint-config-next": "^16.2.10",
|
| 88 |
"happy-dom": "^20.10.6",
|
|
@@ -97,7 +99,8 @@
|
|
| 97 |
"node": ">=22.15.0"
|
| 98 |
},
|
| 99 |
"overrides": {
|
| 100 |
-
"postcss": "^8.5.
|
|
|
|
| 101 |
"sharp": "$sharp"
|
| 102 |
}
|
| 103 |
}
|
|
|
|
| 67 |
"next-themes": "^0.4.6",
|
| 68 |
"openai": "^6.34.0",
|
| 69 |
"pg": "^8.20.0",
|
| 70 |
+
"postcss": "^8.5.18",
|
| 71 |
"react": "^19.0.0",
|
| 72 |
"react-dom": "^19.0.0",
|
| 73 |
"sharp": "^0.35.3",
|
| 74 |
+
"tailwind-merge": "^3.2.0",
|
| 75 |
+
"undici": "^7.21.0"
|
| 76 |
},
|
| 77 |
"devDependencies": {
|
| 78 |
"@eslint/eslintrc": "^3",
|
|
|
|
| 84 |
"@types/pg": "^8.20.0",
|
| 85 |
"@types/react": "^19",
|
| 86 |
"@types/react-dom": "^19",
|
| 87 |
+
"brace-expansion": "file:vendor/brace-expansion-compat",
|
| 88 |
"eslint": "^9",
|
| 89 |
"eslint-config-next": "^16.2.10",
|
| 90 |
"happy-dom": "^20.10.6",
|
|
|
|
| 99 |
"node": ">=22.15.0"
|
| 100 |
},
|
| 101 |
"overrides": {
|
| 102 |
+
"postcss": "^8.5.18",
|
| 103 |
+
"brace-expansion": "$brace-expansion",
|
| 104 |
"sharp": "$sharp"
|
| 105 |
}
|
| 106 |
}
|
public/hf-space-deploy-marker.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
-
"local_sha": "
|
| 4 |
-
"created_at": "2026-07-
|
| 5 |
-
"deploy_id": "
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
+
"local_sha": "4770498acf64bbcda373c6a220f5b5781cef6136",
|
| 4 |
+
"created_at": "2026-07-25T04:50:43.340Z",
|
| 5 |
+
"deploy_id": "28984fcf-867f-419f-bc4c-22acd7ba92c9"
|
| 6 |
}
|
scripts/agent-skill-scripts.test.mjs
CHANGED
|
@@ -4399,10 +4399,12 @@ describe('Agent skill script argument validation', () => {
|
|
| 4399 |
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
|
| 4400 |
|
| 4401 |
assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/);
|
|
|
|
| 4402 |
assert.match(skillText, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/);
|
| 4403 |
assert.match(openAiYaml, /先选择并运行内置脚本/);
|
| 4404 |
assert.match(openAiYaml, /不要临时编写 API 调用脚本/);
|
| 4405 |
assert.match(apiReference, /先使用这些内置脚本/);
|
|
|
|
| 4406 |
assert.match(apiReference, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/);
|
| 4407 |
});
|
| 4408 |
|
|
|
|
| 4399 |
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
|
| 4400 |
|
| 4401 |
assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/);
|
| 4402 |
+
assert.match(skillText, /scripts\/channel-capability-matrix\.mjs/);
|
| 4403 |
assert.match(skillText, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/);
|
| 4404 |
assert.match(openAiYaml, /先选择并运行内置脚本/);
|
| 4405 |
assert.match(openAiYaml, /不要临时编写 API 调用脚本/);
|
| 4406 |
assert.match(apiReference, /先使用这些内置脚本/);
|
| 4407 |
+
assert.match(apiReference, /scripts\/channel-capability-matrix\.mjs/);
|
| 4408 |
assert.match(apiReference, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/);
|
| 4409 |
});
|
| 4410 |
|
scripts/brace-expansion-compat.test.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { createRequire } from 'node:module';
|
| 3 |
+
import { test } from 'node:test';
|
| 4 |
+
|
| 5 |
+
const require = createRequire(import.meta.url);
|
| 6 |
+
|
| 7 |
+
test('brace expansion compatibility facade supports legacy CommonJS consumers', () => {
|
| 8 |
+
const braceExpansion = require('brace-expansion');
|
| 9 |
+
|
| 10 |
+
assert.equal(typeof braceExpansion, 'function');
|
| 11 |
+
assert.equal(typeof braceExpansion.expand, 'function');
|
| 12 |
+
assert.deepEqual(braceExpansion('image-{a,b}.png'), ['image-a.png', 'image-b.png']);
|
| 13 |
+
assert.deepEqual(braceExpansion.expand('image-{a,b}.png'), ['image-a.png', 'image-b.png']);
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
test('brace expansion compatibility facade supports current ESM consumers', async () => {
|
| 17 |
+
const braceExpansion = await import('brace-expansion');
|
| 18 |
+
|
| 19 |
+
assert.equal(typeof braceExpansion.default, 'function');
|
| 20 |
+
assert.equal(typeof braceExpansion.expand, 'function');
|
| 21 |
+
assert.ok(braceExpansion.EXPANSION_MAX > 0);
|
| 22 |
+
assert.deepEqual(braceExpansion.expand('image-{a,b}.png', { max: 1 }), ['image-a.png']);
|
| 23 |
+
});
|
| 24 |
+
|
| 25 |
+
test('brace expansion compatibility facade supports installed minimatch consumer versions', () => {
|
| 26 |
+
const legacyMinimatch = require('minimatch');
|
| 27 |
+
const sortImportsRequire = createRequire(require.resolve('@trivago/prettier-plugin-sort-imports/package.json'));
|
| 28 |
+
const typeScriptEstreeRequire = createRequire(require.resolve('@typescript-eslint/typescript-estree/package.json'));
|
| 29 |
+
const sortImportsMinimatch = sortImportsRequire('minimatch');
|
| 30 |
+
const typeScriptEstreeMinimatch = typeScriptEstreeRequire('minimatch');
|
| 31 |
+
|
| 32 |
+
assert.equal(legacyMinimatch('src/image.ts', 'src/*.{ts,tsx}'), true);
|
| 33 |
+
assert.equal(sortImportsMinimatch.minimatch('src/image.ts', 'src/*.{ts,tsx}'), true);
|
| 34 |
+
assert.equal(typeScriptEstreeMinimatch.minimatch('src/image.ts', 'src/*.{ts,tsx}'), true);
|
| 35 |
+
});
|
scripts/channel-capability-matrix.test.mjs
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import assert from 'node:assert/strict';
|
| 2 |
+
import { spawn } from 'node:child_process';
|
| 3 |
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
| 4 |
+
import { createServer } from 'node:http';
|
| 5 |
+
import { tmpdir } from 'node:os';
|
| 6 |
+
import { join } from 'node:path';
|
| 7 |
+
import { describe, it } from 'node:test';
|
| 8 |
+
import { fileURLToPath } from 'node:url';
|
| 9 |
+
|
| 10 |
+
import {
|
| 11 |
+
FIXTURE_IMAGE_BASE64,
|
| 12 |
+
createFixtureServer
|
| 13 |
+
} from './local-image-upstream-fixture.mjs';
|
| 14 |
+
import {
|
| 15 |
+
buildChannelEnvConfig,
|
| 16 |
+
buildRedactedChannelEnvPreview
|
| 17 |
+
} from '../skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs';
|
| 18 |
+
|
| 19 |
+
const repoRoot = fileURLToPath(new URL('..', import.meta.url));
|
| 20 |
+
const matrixScript = join(repoRoot, 'skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs');
|
| 21 |
+
const testApiKey = 'test-upstream-token';
|
| 22 |
+
const testResponsesModel = 'gpt-5.4';
|
| 23 |
+
|
| 24 |
+
describe('channel capability matrix Skill script', () => {
|
| 25 |
+
it('writes a private directly usable channel configuration after all modes pass', async () => {
|
| 26 |
+
const fixture = await startServer(createFixtureServer());
|
| 27 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-'));
|
| 28 |
+
const outputPath = join(tempRoot, 'channel.env');
|
| 29 |
+
try {
|
| 30 |
+
const result = await runMatrix(
|
| 31 |
+
[
|
| 32 |
+
'--base-url',
|
| 33 |
+
`${fixture.baseUrl}/v1`,
|
| 34 |
+
'--responses-model',
|
| 35 |
+
testResponsesModel,
|
| 36 |
+
'--allow-billable',
|
| 37 |
+
'--write-env-file',
|
| 38 |
+
outputPath,
|
| 39 |
+
'--channel-id',
|
| 40 |
+
'fixture-upstream',
|
| 41 |
+
'--timeout-ms',
|
| 42 |
+
'5000'
|
| 43 |
+
],
|
| 44 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 45 |
+
);
|
| 46 |
+
|
| 47 |
+
assert.equal(result.status, 0);
|
| 48 |
+
assert.equal(result.stderr.trim(), '');
|
| 49 |
+
assert.doesNotMatch(result.stdout, new RegExp(testApiKey));
|
| 50 |
+
const report = JSON.parse(result.stdout);
|
| 51 |
+
assert.equal(report.ok, true);
|
| 52 |
+
assert.deepEqual(report.matrix.passed, [
|
| 53 |
+
'images-non-stream',
|
| 54 |
+
'images-sse',
|
| 55 |
+
'responses-non-stream',
|
| 56 |
+
'responses-sse'
|
| 57 |
+
]);
|
| 58 |
+
assert.deepEqual(report.matrix.failed, []);
|
| 59 |
+
assert.equal(report.configuration.ready, true);
|
| 60 |
+
assert.equal(report.write.written, true);
|
| 61 |
+
assert.deepEqual(report.configuration.env_preview, [
|
| 62 |
+
'OPENAI_CHANNEL_1_ID=fixture-upstream',
|
| 63 |
+
`OPENAI_CHANNEL_1_BASE_URL=${fixture.baseUrl}/v1`,
|
| 64 |
+
'OPENAI_CHANNEL_1_API_KEYS=[redacted]',
|
| 65 |
+
'OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse',
|
| 66 |
+
'OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse',
|
| 67 |
+
'IMAGE_GENERATION_BACKEND=images-api',
|
| 68 |
+
'IMAGE_STREAMING_STRATEGY=auto',
|
| 69 |
+
'ENABLE_RESPONSES_IMAGE_BACKEND=true',
|
| 70 |
+
`OPENAI_RESPONSES_API_MODEL=${testResponsesModel}`
|
| 71 |
+
]);
|
| 72 |
+
|
| 73 |
+
const content = readFileSync(outputPath, 'utf8');
|
| 74 |
+
assert.match(content, /OPENAI_CHANNEL_1_ID=fixture-upstream/);
|
| 75 |
+
assert.match(content, new RegExp(`OPENAI_CHANNEL_1_BASE_URL=${escapeRegExp(`${fixture.baseUrl}/v1`)}`));
|
| 76 |
+
assert.match(content, new RegExp(`OPENAI_CHANNEL_1_API_KEYS=${testApiKey}`));
|
| 77 |
+
assert.match(content, /OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse/);
|
| 78 |
+
assert.match(content, /OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse/);
|
| 79 |
+
assert.match(content, /IMAGE_GENERATION_BACKEND=images-api/);
|
| 80 |
+
assert.match(content, /IMAGE_STREAMING_STRATEGY=auto/);
|
| 81 |
+
assert.match(content, /ENABLE_RESPONSES_IMAGE_BACKEND=true/);
|
| 82 |
+
assert.match(content, new RegExp(`OPENAI_RESPONSES_API_MODEL=${testResponsesModel}`));
|
| 83 |
+
assert.equal(statSync(outputPath).mode & 0o777, 0o600);
|
| 84 |
+
|
| 85 |
+
const resolvedConfig = await resolveGeneratedConfig(outputPath);
|
| 86 |
+
assert.equal(resolvedConfig.status, 0);
|
| 87 |
+
assert.equal(resolvedConfig.stderr.trim(), '');
|
| 88 |
+
assert.deepEqual(resolvedConfig.value, {
|
| 89 |
+
channel: {
|
| 90 |
+
id: 'fixture-upstream',
|
| 91 |
+
base_url: `${fixture.baseUrl}/v1`,
|
| 92 |
+
request_modes: [
|
| 93 |
+
'images-non-stream',
|
| 94 |
+
'images-sse',
|
| 95 |
+
'responses-non-stream',
|
| 96 |
+
'responses-sse'
|
| 97 |
+
],
|
| 98 |
+
request_mode_priority: [
|
| 99 |
+
'images-non-stream',
|
| 100 |
+
'images-sse',
|
| 101 |
+
'responses-non-stream',
|
| 102 |
+
'responses-sse'
|
| 103 |
+
]
|
| 104 |
+
},
|
| 105 |
+
image_backend: 'images-api',
|
| 106 |
+
streaming_strategy: 'auto'
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
const refused = await runMatrix(
|
| 110 |
+
[
|
| 111 |
+
'--base-url',
|
| 112 |
+
`${fixture.baseUrl}/v1`,
|
| 113 |
+
'--responses-model',
|
| 114 |
+
testResponsesModel,
|
| 115 |
+
'--allow-billable',
|
| 116 |
+
'--write-env-file',
|
| 117 |
+
outputPath,
|
| 118 |
+
'--channel-id',
|
| 119 |
+
'fixture-upstream',
|
| 120 |
+
'--timeout-ms',
|
| 121 |
+
'5000'
|
| 122 |
+
],
|
| 123 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 124 |
+
);
|
| 125 |
+
assert.equal(refused.status, 2);
|
| 126 |
+
assert.match(refused.stderr, /已存在/);
|
| 127 |
+
assert.equal(refused.stdout.trim(), '');
|
| 128 |
+
|
| 129 |
+
const overwritten = await runMatrix(
|
| 130 |
+
[
|
| 131 |
+
'--base-url',
|
| 132 |
+
`${fixture.baseUrl}/v1`,
|
| 133 |
+
'--responses-model',
|
| 134 |
+
testResponsesModel,
|
| 135 |
+
'--allow-billable',
|
| 136 |
+
'--write-env-file',
|
| 137 |
+
outputPath,
|
| 138 |
+
'--channel-id',
|
| 139 |
+
'fixture-upstream',
|
| 140 |
+
'--timeout-ms',
|
| 141 |
+
'5000',
|
| 142 |
+
'--overwrite'
|
| 143 |
+
],
|
| 144 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 145 |
+
);
|
| 146 |
+
assert.equal(overwritten.status, 0);
|
| 147 |
+
assert.equal(JSON.parse(overwritten.stdout).write.written, true);
|
| 148 |
+
assert.equal(statSync(outputPath).mode & 0o777, 0o600);
|
| 149 |
+
} finally {
|
| 150 |
+
await fixture.close();
|
| 151 |
+
rmSync(tempRoot, { recursive: true, force: true });
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it('writes only the verified Images API mode and preserves fixed probe order', async () => {
|
| 156 |
+
const calls = [];
|
| 157 |
+
const fixture = await startServer(
|
| 158 |
+
createServer(async (request, response) => {
|
| 159 |
+
const url = new URL(request.url || '/', 'http://fixture.local');
|
| 160 |
+
if (request.method === 'GET' && url.pathname === '/v1/models') {
|
| 161 |
+
calls.push({ method: request.method, path: url.pathname });
|
| 162 |
+
sendJson(response, 200, { data: [{ id: 'gpt-image-2' }, { id: testResponsesModel }] });
|
| 163 |
+
return;
|
| 164 |
+
}
|
| 165 |
+
if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
|
| 166 |
+
const body = await readJsonBody(request);
|
| 167 |
+
calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
|
| 168 |
+
if (body.stream === true) {
|
| 169 |
+
sendJson(response, 502, { error: { message: 'images sse unavailable' } });
|
| 170 |
+
return;
|
| 171 |
+
}
|
| 172 |
+
sendJson(response, 200, { data: [{ b64_json: FIXTURE_IMAGE_BASE64 }] });
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
if (request.method === 'POST' && url.pathname === '/v1/responses') {
|
| 176 |
+
const body = await readJsonBody(request);
|
| 177 |
+
calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
|
| 178 |
+
sendJson(response, 503, { error: { message: 'responses unavailable' } });
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
sendJson(response, 404, { error: { message: 'unknown route' } });
|
| 182 |
+
})
|
| 183 |
+
);
|
| 184 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-partial-'));
|
| 185 |
+
const outputPath = join(tempRoot, 'partial.env');
|
| 186 |
+
try {
|
| 187 |
+
const result = await runMatrix(
|
| 188 |
+
[
|
| 189 |
+
'--base-url',
|
| 190 |
+
`${fixture.baseUrl}/v1`,
|
| 191 |
+
'--responses-model',
|
| 192 |
+
testResponsesModel,
|
| 193 |
+
'--allow-billable',
|
| 194 |
+
'--write-env-file',
|
| 195 |
+
outputPath,
|
| 196 |
+
'--channel-index',
|
| 197 |
+
'2',
|
| 198 |
+
'--channel-id',
|
| 199 |
+
'images-only',
|
| 200 |
+
'--timeout-ms',
|
| 201 |
+
'5000'
|
| 202 |
+
],
|
| 203 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 204 |
+
);
|
| 205 |
+
|
| 206 |
+
assert.equal(result.status, 0);
|
| 207 |
+
const report = JSON.parse(result.stdout);
|
| 208 |
+
assert.deepEqual(report.matrix.passed, ['images-non-stream']);
|
| 209 |
+
assert.deepEqual(report.matrix.failed, ['images-sse', 'responses-non-stream', 'responses-sse']);
|
| 210 |
+
assert.equal(report.matrix.coverage_complete, true);
|
| 211 |
+
assert.equal(report.configuration.ready, true);
|
| 212 |
+
assert.equal(report.write.written, true);
|
| 213 |
+
assert.deepEqual(calls, [
|
| 214 |
+
{ method: 'GET', path: '/v1/models' },
|
| 215 |
+
{ method: 'POST', path: '/v1/images/generations', stream: false },
|
| 216 |
+
{ method: 'POST', path: '/v1/images/generations', stream: true },
|
| 217 |
+
{ method: 'POST', path: '/v1/responses', stream: false },
|
| 218 |
+
{ method: 'POST', path: '/v1/responses', stream: true }
|
| 219 |
+
]);
|
| 220 |
+
|
| 221 |
+
const content = readFileSync(outputPath, 'utf8');
|
| 222 |
+
assert.match(content, /OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream/);
|
| 223 |
+
assert.match(content, /OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY=images-non-stream/);
|
| 224 |
+
assert.match(content, /IMAGE_GENERATION_BACKEND=images-api/);
|
| 225 |
+
assert.match(content, /IMAGE_STREAMING_STRATEGY=auto/);
|
| 226 |
+
assert.doesNotMatch(content, /ENABLE_RESPONSES_IMAGE_BACKEND/);
|
| 227 |
+
assert.doesNotMatch(content, /OPENAI_RESPONSES_API_MODEL/);
|
| 228 |
+
} finally {
|
| 229 |
+
await fixture.close();
|
| 230 |
+
rmSync(tempRoot, { recursive: true, force: true });
|
| 231 |
+
}
|
| 232 |
+
});
|
| 233 |
+
|
| 234 |
+
it('selects the Responses backend when no Images API request mode is usable', async () => {
|
| 235 |
+
const calls = [];
|
| 236 |
+
const fixture = await startServer(
|
| 237 |
+
createServer(async (request, response) => {
|
| 238 |
+
const url = new URL(request.url || '/', 'http://fixture.local');
|
| 239 |
+
if (request.method === 'GET' && url.pathname === '/v1/models') {
|
| 240 |
+
calls.push({ method: request.method, path: url.pathname });
|
| 241 |
+
sendJson(response, 200, { data: [{ id: 'gpt-image-2' }, { id: testResponsesModel }] });
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
|
| 245 |
+
const body = await readJsonBody(request);
|
| 246 |
+
calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
|
| 247 |
+
sendJson(response, 503, { error: { message: 'images unavailable' } });
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
if (request.method === 'POST' && url.pathname === '/v1/responses') {
|
| 251 |
+
const body = await readJsonBody(request);
|
| 252 |
+
calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
|
| 253 |
+
if (body.stream === true) {
|
| 254 |
+
sendJson(response, 503, { error: { message: 'responses sse unavailable' } });
|
| 255 |
+
return;
|
| 256 |
+
}
|
| 257 |
+
sendJson(response, 200, {
|
| 258 |
+
output: [
|
| 259 |
+
{
|
| 260 |
+
type: 'image_generation_call',
|
| 261 |
+
status: 'completed',
|
| 262 |
+
result: `data:image/png;base64,${FIXTURE_IMAGE_BASE64}`
|
| 263 |
+
}
|
| 264 |
+
]
|
| 265 |
+
});
|
| 266 |
+
return;
|
| 267 |
+
}
|
| 268 |
+
sendJson(response, 404, { error: { message: 'unknown route' } });
|
| 269 |
+
})
|
| 270 |
+
);
|
| 271 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-responses-only-'));
|
| 272 |
+
const outputPath = join(tempRoot, 'responses-only.env');
|
| 273 |
+
try {
|
| 274 |
+
const result = await runMatrix(
|
| 275 |
+
[
|
| 276 |
+
'--base-url',
|
| 277 |
+
`${fixture.baseUrl}/v1`,
|
| 278 |
+
'--responses-model',
|
| 279 |
+
testResponsesModel,
|
| 280 |
+
'--allow-billable',
|
| 281 |
+
'--write-env-file',
|
| 282 |
+
outputPath,
|
| 283 |
+
'--channel-index',
|
| 284 |
+
'3',
|
| 285 |
+
'--channel-id',
|
| 286 |
+
'responses-only',
|
| 287 |
+
'--timeout-ms',
|
| 288 |
+
'5000'
|
| 289 |
+
],
|
| 290 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 291 |
+
);
|
| 292 |
+
|
| 293 |
+
assert.equal(result.status, 0);
|
| 294 |
+
const report = JSON.parse(result.stdout);
|
| 295 |
+
assert.deepEqual(report.matrix.passed, ['responses-non-stream']);
|
| 296 |
+
assert.deepEqual(report.matrix.failed, ['images-non-stream', 'images-sse', 'responses-sse']);
|
| 297 |
+
assert.equal(report.configuration.ready, true);
|
| 298 |
+
assert.equal(report.configuration.image_backend, 'responses-image-generation');
|
| 299 |
+
assert.equal(report.configuration.streaming_strategy, 'auto');
|
| 300 |
+
assert.equal(report.write.written, true);
|
| 301 |
+
assert.deepEqual(calls, [
|
| 302 |
+
{ method: 'GET', path: '/v1/models' },
|
| 303 |
+
{ method: 'POST', path: '/v1/images/generations', stream: false },
|
| 304 |
+
{ method: 'POST', path: '/v1/images/generations', stream: true },
|
| 305 |
+
{ method: 'POST', path: '/v1/responses', stream: false },
|
| 306 |
+
{ method: 'POST', path: '/v1/responses', stream: true }
|
| 307 |
+
]);
|
| 308 |
+
|
| 309 |
+
const content = readFileSync(outputPath, 'utf8');
|
| 310 |
+
assert.match(content, /OPENAI_CHANNEL_3_REQUEST_MODES=responses-non-stream/);
|
| 311 |
+
assert.match(content, /OPENAI_CHANNEL_3_REQUEST_MODE_PRIORITY=responses-non-stream/);
|
| 312 |
+
assert.match(content, /IMAGE_GENERATION_BACKEND=responses-image-generation/);
|
| 313 |
+
assert.match(content, /IMAGE_STREAMING_STRATEGY=auto/);
|
| 314 |
+
assert.match(content, /ENABLE_RESPONSES_IMAGE_BACKEND=true/);
|
| 315 |
+
assert.match(content, new RegExp(`OPENAI_RESPONSES_API_MODEL=${testResponsesModel}`));
|
| 316 |
+
|
| 317 |
+
const resolvedConfig = await resolveGeneratedConfig(outputPath);
|
| 318 |
+
assert.equal(resolvedConfig.status, 0);
|
| 319 |
+
assert.equal(resolvedConfig.stderr.trim(), '');
|
| 320 |
+
assert.equal(resolvedConfig.value.channel.id, 'responses-only');
|
| 321 |
+
assert.deepEqual(resolvedConfig.value.channel.request_modes, ['responses-non-stream']);
|
| 322 |
+
assert.equal(resolvedConfig.value.image_backend, 'responses-image-generation');
|
| 323 |
+
assert.equal(resolvedConfig.value.streaming_strategy, 'auto');
|
| 324 |
+
} finally {
|
| 325 |
+
await fixture.close();
|
| 326 |
+
rmSync(tempRoot, { recursive: true, force: true });
|
| 327 |
+
}
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
it('refuses a remote URL-only result and leaves the target absent', async () => {
|
| 331 |
+
const fixture = await startServer(
|
| 332 |
+
createServer(async (request, response) => {
|
| 333 |
+
const url = new URL(request.url || '/', 'http://fixture.local');
|
| 334 |
+
if (request.method === 'GET' && url.pathname === '/v1/models') {
|
| 335 |
+
sendJson(response, 200, { data: [{ id: 'gpt-image-2' }, { id: testResponsesModel }] });
|
| 336 |
+
return;
|
| 337 |
+
}
|
| 338 |
+
if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
|
| 339 |
+
await readJsonBody(request);
|
| 340 |
+
sendJson(response, 200, { data: [{ url: 'https://cdn.example.test/generated.png' }] });
|
| 341 |
+
return;
|
| 342 |
+
}
|
| 343 |
+
if (request.method === 'POST' && url.pathname === '/v1/responses') {
|
| 344 |
+
await readJsonBody(request);
|
| 345 |
+
sendJson(response, 503, { error: { message: 'responses unavailable' } });
|
| 346 |
+
return;
|
| 347 |
+
}
|
| 348 |
+
sendJson(response, 404, { error: { message: 'unknown route' } });
|
| 349 |
+
})
|
| 350 |
+
);
|
| 351 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-remote-url-'));
|
| 352 |
+
const outputPath = join(tempRoot, 'remote-url.env');
|
| 353 |
+
try {
|
| 354 |
+
const result = await runMatrix(
|
| 355 |
+
[
|
| 356 |
+
'--base-url',
|
| 357 |
+
`${fixture.baseUrl}/v1`,
|
| 358 |
+
'--responses-model',
|
| 359 |
+
testResponsesModel,
|
| 360 |
+
'--allow-billable',
|
| 361 |
+
'--write-env-file',
|
| 362 |
+
outputPath,
|
| 363 |
+
'--channel-id',
|
| 364 |
+
'remote-url-only',
|
| 365 |
+
'--timeout-ms',
|
| 366 |
+
'5000'
|
| 367 |
+
],
|
| 368 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 369 |
+
);
|
| 370 |
+
|
| 371 |
+
assert.equal(result.status, 1);
|
| 372 |
+
assert.doesNotMatch(result.stdout, new RegExp(testApiKey));
|
| 373 |
+
const report = JSON.parse(result.stdout);
|
| 374 |
+
assert.deepEqual(report.matrix.passed, []);
|
| 375 |
+
assert.equal(report.matrix.modes['images-non-stream'].has_remote_url_result, true);
|
| 376 |
+
assert.equal(report.configuration.ready, false);
|
| 377 |
+
assert.deepEqual(report.configuration.blocking_reasons, ['no_consumable_image_mode']);
|
| 378 |
+
assert.equal(report.write.written, false);
|
| 379 |
+
assert.equal(report.write.reason, 'configuration_not_ready');
|
| 380 |
+
assert.equal(existsSync(outputPath), false);
|
| 381 |
+
} finally {
|
| 382 |
+
await fixture.close();
|
| 383 |
+
rmSync(tempRoot, { recursive: true, force: true });
|
| 384 |
+
}
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
it('requires explicit billable permission before it accepts a private output target', async () => {
|
| 388 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-non-billable-'));
|
| 389 |
+
const outputPath = join(tempRoot, 'unverified.env');
|
| 390 |
+
try {
|
| 391 |
+
const result = await runMatrix(
|
| 392 |
+
['--base-url', 'http://127.0.0.1:9/v1', '--write-env-file', outputPath],
|
| 393 |
+
{ GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
|
| 394 |
+
);
|
| 395 |
+
|
| 396 |
+
assert.equal(result.status, 2);
|
| 397 |
+
assert.match(result.stderr, /--allow-billable/);
|
| 398 |
+
assert.equal(result.stdout.trim(), '');
|
| 399 |
+
assert.equal(existsSync(outputPath), false);
|
| 400 |
+
} finally {
|
| 401 |
+
rmSync(tempRoot, { recursive: true, force: true });
|
| 402 |
+
}
|
| 403 |
+
});
|
| 404 |
+
|
| 405 |
+
it('includes the exact allowlist required for a remote plain HTTP upstream', async () => {
|
| 406 |
+
const baseUrl = 'http://images.internal.example.test/v1';
|
| 407 |
+
const config = buildChannelEnvConfig({
|
| 408 |
+
channelIndex: 4,
|
| 409 |
+
channelId: 'plain-http',
|
| 410 |
+
baseUrl,
|
| 411 |
+
apiKey: testApiKey,
|
| 412 |
+
requestModes: ['images-non-stream']
|
| 413 |
+
});
|
| 414 |
+
const preview = buildRedactedChannelEnvPreview({
|
| 415 |
+
channelIndex: 4,
|
| 416 |
+
channelId: 'plain-http',
|
| 417 |
+
baseUrl,
|
| 418 |
+
requestModes: ['images-non-stream']
|
| 419 |
+
});
|
| 420 |
+
|
| 421 |
+
assert.match(config, new RegExp(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${escapeRegExp(baseUrl)}`));
|
| 422 |
+
assert.ok(preview.includes(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${baseUrl}`));
|
| 423 |
+
assert.doesNotMatch(config, /OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http:\/\/127\.0\.0\.1/);
|
| 424 |
+
|
| 425 |
+
const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-plain-http-'));
|
| 426 |
+
const outputPath = join(tempRoot, 'plain-http.env');
|
| 427 |
+
try {
|
| 428 |
+
writeFileSync(outputPath, config, { mode: 0o600 });
|
| 429 |
+
const resolvedConfig = await resolveGeneratedConfig(outputPath);
|
| 430 |
+
assert.equal(resolvedConfig.status, 0);
|
| 431 |
+
assert.equal(resolvedConfig.stderr.trim(), '');
|
| 432 |
+
assert.equal(resolvedConfig.value.channel.base_url, baseUrl);
|
| 433 |
+
assert.deepEqual(resolvedConfig.value.channel.request_modes, ['images-non-stream']);
|
| 434 |
+
assert.equal(resolvedConfig.value.image_backend, 'images-api');
|
| 435 |
+
} finally {
|
| 436 |
+
rmSync(tempRoot, { recursive: true, force: true });
|
| 437 |
+
}
|
| 438 |
+
});
|
| 439 |
+
});
|
| 440 |
+
|
| 441 |
+
async function startServer(server) {
|
| 442 |
+
await new Promise((resolve, reject) => {
|
| 443 |
+
server.once('error', reject);
|
| 444 |
+
server.listen(0, '127.0.0.1', resolve);
|
| 445 |
+
});
|
| 446 |
+
const address = server.address();
|
| 447 |
+
assert.ok(address && typeof address === 'object');
|
| 448 |
+
return {
|
| 449 |
+
baseUrl: `http://127.0.0.1:${address.port}`,
|
| 450 |
+
close: () => closeServer(server)
|
| 451 |
+
};
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
function closeServer(server) {
|
| 455 |
+
return new Promise((resolve, reject) => {
|
| 456 |
+
server.close((error) => {
|
| 457 |
+
if (error) reject(error);
|
| 458 |
+
else resolve();
|
| 459 |
+
});
|
| 460 |
+
});
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
function runMatrix(args, env) {
|
| 464 |
+
return new Promise((resolveResult) => {
|
| 465 |
+
const child = spawn(process.execPath, [matrixScript, ...args], {
|
| 466 |
+
cwd: repoRoot,
|
| 467 |
+
env: {
|
| 468 |
+
...buildIsolatedEnvironment(),
|
| 469 |
+
...env
|
| 470 |
+
},
|
| 471 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 472 |
+
});
|
| 473 |
+
let stdout = '';
|
| 474 |
+
let stderr = '';
|
| 475 |
+
child.stdout.setEncoding('utf8');
|
| 476 |
+
child.stderr.setEncoding('utf8');
|
| 477 |
+
child.stdout.on('data', (chunk) => {
|
| 478 |
+
stdout += chunk;
|
| 479 |
+
});
|
| 480 |
+
child.stderr.on('data', (chunk) => {
|
| 481 |
+
stderr += chunk;
|
| 482 |
+
});
|
| 483 |
+
child.once('error', () => {
|
| 484 |
+
resolveResult({ status: undefined, stdout, stderr });
|
| 485 |
+
});
|
| 486 |
+
child.once('close', (status) => {
|
| 487 |
+
resolveResult({ status: status ?? undefined, stdout, stderr });
|
| 488 |
+
});
|
| 489 |
+
});
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function resolveGeneratedConfig(envFilePath) {
|
| 493 |
+
const script = [
|
| 494 |
+
"import { parseChannelPoolConfig } from './src/lib/channel-router.ts';",
|
| 495 |
+
"import { readImageGenerationBackend, readImageStreamingStrategy } from './src/lib/image-upstream-strategy.ts';",
|
| 496 |
+
'const formData = new FormData();',
|
| 497 |
+
'const config = parseChannelPoolConfig(process.env);',
|
| 498 |
+
'const credential = config.credentials[0];',
|
| 499 |
+
'console.log(JSON.stringify({',
|
| 500 |
+
' channel: {',
|
| 501 |
+
' id: credential.channelId,',
|
| 502 |
+
' base_url: credential.baseUrl,',
|
| 503 |
+
' request_modes: credential.requestModes,',
|
| 504 |
+
' request_mode_priority: credential.requestModePriority',
|
| 505 |
+
' },',
|
| 506 |
+
' image_backend: readImageGenerationBackend(formData),',
|
| 507 |
+
' streaming_strategy: readImageStreamingStrategy(formData)',
|
| 508 |
+
'}));'
|
| 509 |
+
].join('\n');
|
| 510 |
+
return new Promise((resolveResult) => {
|
| 511 |
+
const child = spawn(process.execPath, ['--env-file', envFilePath, '--import', 'tsx', '--input-type=module', '--eval', script], {
|
| 512 |
+
cwd: repoRoot,
|
| 513 |
+
env: buildIsolatedEnvironment(),
|
| 514 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 515 |
+
});
|
| 516 |
+
let stdout = '';
|
| 517 |
+
let stderr = '';
|
| 518 |
+
child.stdout.setEncoding('utf8');
|
| 519 |
+
child.stderr.setEncoding('utf8');
|
| 520 |
+
child.stdout.on('data', (chunk) => {
|
| 521 |
+
stdout += chunk;
|
| 522 |
+
});
|
| 523 |
+
child.stderr.on('data', (chunk) => {
|
| 524 |
+
stderr += chunk;
|
| 525 |
+
});
|
| 526 |
+
child.once('error', () => {
|
| 527 |
+
resolveResult({ status: undefined, stderr, value: undefined });
|
| 528 |
+
});
|
| 529 |
+
child.once('close', (status) => {
|
| 530 |
+
let value;
|
| 531 |
+
try {
|
| 532 |
+
value = JSON.parse(stdout);
|
| 533 |
+
} catch {}
|
| 534 |
+
resolveResult({ status: status ?? undefined, stderr, value });
|
| 535 |
+
});
|
| 536 |
+
});
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
function buildIsolatedEnvironment() {
|
| 540 |
+
const keepNames = ['HOME', 'PATH', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'USERPROFILE'];
|
| 541 |
+
const environment = { GPT_IMAGE_AGENT_LOAD_ENV_FILE: '0' };
|
| 542 |
+
for (const name of keepNames) {
|
| 543 |
+
if (process.env[name] !== undefined) environment[name] = process.env[name];
|
| 544 |
+
}
|
| 545 |
+
return environment;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
async function readJsonBody(request) {
|
| 549 |
+
let text = '';
|
| 550 |
+
request.setEncoding('utf8');
|
| 551 |
+
for await (const chunk of request) text += chunk;
|
| 552 |
+
return text ? JSON.parse(text) : {};
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function sendJson(response, status, body) {
|
| 556 |
+
response.writeHead(status, { 'content-type': 'application/json' });
|
| 557 |
+
response.end(JSON.stringify(body));
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
function escapeRegExp(value) {
|
| 561 |
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
| 562 |
+
}
|
scripts/dependency-installation.mjs
CHANGED
|
@@ -79,13 +79,13 @@ function collectDirectDependencies(rootPackage) {
|
|
| 79 |
}
|
| 80 |
|
| 81 |
function collectRootLockMismatches(directDependencies, packages) {
|
| 82 |
-
return directDependencies.filter((name) => !readPackageVersion(packages
|
| 83 |
}
|
| 84 |
|
| 85 |
function collectHiddenLockMismatches(directDependencies, rootPackages, hiddenPackages) {
|
| 86 |
return directDependencies.flatMap((name) => {
|
| 87 |
-
const expected = readPackageVersion(rootPackages
|
| 88 |
-
const actual = readPackageVersion(hiddenPackages
|
| 89 |
return actual === expected ? [] : [{ name, expected, actual }];
|
| 90 |
});
|
| 91 |
}
|
|
@@ -107,7 +107,7 @@ function inspectDirectPackageManifests(nodeModulesPath, directDependencies, root
|
|
| 107 |
continue;
|
| 108 |
}
|
| 109 |
if (manifest.name !== name) nameMismatches.push({ expected: name, actual: manifest.name });
|
| 110 |
-
const expected = readPackageVersion(rootPackages
|
| 111 |
if (manifest.version !== expected) versionMismatches.push({ name, expected, actual: manifest.version });
|
| 112 |
}
|
| 113 |
return { missingPackages, invalidPackages, nameMismatches, versionMismatches };
|
|
@@ -125,8 +125,13 @@ function readPackageManifest(path) {
|
|
| 125 |
}
|
| 126 |
}
|
| 127 |
|
| 128 |
-
function readPackageVersion(
|
| 129 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
}
|
| 131 |
|
| 132 |
function buildFailure(reason, details = {}) {
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
function collectRootLockMismatches(directDependencies, packages) {
|
| 82 |
+
return directDependencies.filter((name) => !readPackageVersion(packages, `node_modules/${name}`));
|
| 83 |
}
|
| 84 |
|
| 85 |
function collectHiddenLockMismatches(directDependencies, rootPackages, hiddenPackages) {
|
| 86 |
return directDependencies.flatMap((name) => {
|
| 87 |
+
const expected = readPackageVersion(rootPackages, `node_modules/${name}`);
|
| 88 |
+
const actual = readPackageVersion(hiddenPackages, `node_modules/${name}`);
|
| 89 |
return actual === expected ? [] : [{ name, expected, actual }];
|
| 90 |
});
|
| 91 |
}
|
|
|
|
| 107 |
continue;
|
| 108 |
}
|
| 109 |
if (manifest.name !== name) nameMismatches.push({ expected: name, actual: manifest.name });
|
| 110 |
+
const expected = readPackageVersion(rootPackages, `node_modules/${name}`);
|
| 111 |
if (manifest.version !== expected) versionMismatches.push({ name, expected, actual: manifest.version });
|
| 112 |
}
|
| 113 |
return { missingPackages, invalidPackages, nameMismatches, versionMismatches };
|
|
|
|
| 125 |
}
|
| 126 |
}
|
| 127 |
|
| 128 |
+
function readPackageVersion(packages, packagePath, visited = new Set()) {
|
| 129 |
+
if (visited.has(packagePath)) return undefined;
|
| 130 |
+
visited.add(packagePath);
|
| 131 |
+
const manifest = packages[packagePath];
|
| 132 |
+
if (typeof manifest?.version === 'string' && manifest.version.length > 0) return manifest.version;
|
| 133 |
+
if (!manifest?.link || typeof manifest.resolved !== 'string') return undefined;
|
| 134 |
+
return readPackageVersion(packages, manifest.resolved, visited);
|
| 135 |
}
|
| 136 |
|
| 137 |
function buildFailure(reason, details = {}) {
|
scripts/dependency-installation.test.mjs
CHANGED
|
@@ -19,10 +19,29 @@ const ROOT_LOCKFILE = {
|
|
| 19 |
}
|
| 20 |
};
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
const root = await mkdtemp(join(tmpdir(), 'gipc-dependency-installation-'));
|
| 24 |
await mkdir(join(root, 'node_modules'), { recursive: true });
|
| 25 |
-
await writeFile(join(root, 'package-lock.json'), JSON.stringify(
|
| 26 |
if (hiddenLockfile !== undefined) {
|
| 27 |
await writeFile(join(root, 'node_modules', '.package-lock.json'), JSON.stringify(hiddenLockfile));
|
| 28 |
}
|
|
@@ -105,4 +124,45 @@ describe('dependency installation inspection', () => {
|
|
| 105 |
await rm(root, { force: true, recursive: true });
|
| 106 |
}
|
| 107 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
});
|
|
|
|
| 19 |
}
|
| 20 |
};
|
| 21 |
|
| 22 |
+
const LOCAL_LINK_ROOT_LOCKFILE = {
|
| 23 |
+
name: 'fixture',
|
| 24 |
+
lockfileVersion: 3,
|
| 25 |
+
packages: {
|
| 26 |
+
'': {
|
| 27 |
+
name: 'fixture',
|
| 28 |
+
dependencies: { demo: 'file:vendor/demo' }
|
| 29 |
+
},
|
| 30 |
+
'node_modules/demo': {
|
| 31 |
+
resolved: 'vendor/demo',
|
| 32 |
+
link: true
|
| 33 |
+
},
|
| 34 |
+
'vendor/demo': {
|
| 35 |
+
name: 'demo',
|
| 36 |
+
version: '1.0.0'
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
async function createFixture({ rootLockfile = ROOT_LOCKFILE, hiddenLockfile, manifests = {} } = {}) {
|
| 42 |
const root = await mkdtemp(join(tmpdir(), 'gipc-dependency-installation-'));
|
| 43 |
await mkdir(join(root, 'node_modules'), { recursive: true });
|
| 44 |
+
await writeFile(join(root, 'package-lock.json'), JSON.stringify(rootLockfile));
|
| 45 |
if (hiddenLockfile !== undefined) {
|
| 46 |
await writeFile(join(root, 'node_modules', '.package-lock.json'), JSON.stringify(hiddenLockfile));
|
| 47 |
}
|
|
|
|
| 124 |
await rm(root, { force: true, recursive: true });
|
| 125 |
}
|
| 126 |
});
|
| 127 |
+
|
| 128 |
+
it('accepts a local file dependency whose link target matches both lockfiles', async () => {
|
| 129 |
+
const root = await createFixture({
|
| 130 |
+
rootLockfile: LOCAL_LINK_ROOT_LOCKFILE,
|
| 131 |
+
hiddenLockfile: buildHiddenLockfile(LOCAL_LINK_ROOT_LOCKFILE.packages),
|
| 132 |
+
manifests: {
|
| 133 |
+
demo: { name: 'demo', version: '1.0.0' }
|
| 134 |
+
}
|
| 135 |
+
});
|
| 136 |
+
try {
|
| 137 |
+
const state = inspectDependencyInstallation(root);
|
| 138 |
+
|
| 139 |
+
assert.equal(state.ok, true);
|
| 140 |
+
assert.deepEqual(state.directDependencies, ['demo']);
|
| 141 |
+
} finally {
|
| 142 |
+
await rm(root, { force: true, recursive: true });
|
| 143 |
+
}
|
| 144 |
+
});
|
| 145 |
+
|
| 146 |
+
it('detects a local file dependency version mismatch in the hidden lockfile', async () => {
|
| 147 |
+
const hiddenLockfile = buildHiddenLockfile({
|
| 148 |
+
...LOCAL_LINK_ROOT_LOCKFILE.packages,
|
| 149 |
+
'vendor/demo': { name: 'demo', version: '1.1.0' }
|
| 150 |
+
});
|
| 151 |
+
const root = await createFixture({
|
| 152 |
+
rootLockfile: LOCAL_LINK_ROOT_LOCKFILE,
|
| 153 |
+
hiddenLockfile,
|
| 154 |
+
manifests: {
|
| 155 |
+
demo: { name: 'demo', version: '1.0.0' }
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
try {
|
| 159 |
+
const state = inspectDependencyInstallation(root);
|
| 160 |
+
|
| 161 |
+
assert.equal(state.ok, false);
|
| 162 |
+
assert.equal(state.reason, 'hidden_lockfile_package_mismatch');
|
| 163 |
+
assert.deepEqual(state.hiddenLockMismatches, [{ name: 'demo', expected: '1.0.0', actual: '1.1.0' }]);
|
| 164 |
+
} finally {
|
| 165 |
+
await rm(root, { force: true, recursive: true });
|
| 166 |
+
}
|
| 167 |
+
});
|
| 168 |
});
|
scripts/docker-build-context.test.mjs
CHANGED
|
@@ -16,6 +16,7 @@ describe('Docker build context', () => {
|
|
| 16 |
assert.match(dockerignore, /^\.env\.\*$/m);
|
| 17 |
assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
|
| 18 |
assert.match(dockerfile, /^COPY \. \.$/m);
|
|
|
|
| 19 |
assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
|
| 20 |
assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
|
| 21 |
});
|
|
|
|
| 16 |
assert.match(dockerignore, /^\.env\.\*$/m);
|
| 17 |
assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
|
| 18 |
assert.match(dockerfile, /^COPY \. \.$/m);
|
| 19 |
+
assert.match(dockerfile, /^COPY vendor\/brace-expansion-compat \.\/vendor\/brace-expansion-compat$/m);
|
| 20 |
assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
|
| 21 |
assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
|
| 22 |
});
|
scripts/env-summary.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { isMainModule, printJson, runCommand } from './command-center-utils.mjs'
|
|
| 6 |
|
| 7 |
const DEFAULT_ENV_FILES = ['.env.local', '.env.real-smoke.local', '.env.agent.local'];
|
| 8 |
const SECRET_NAME_PATTERN = /(API_?KEY|API_?KEYS|TOKEN|PASSWORD|SECRET|CREDENTIAL|PRIVATE)/i;
|
|
|
|
| 9 |
const URL_NAME_PATTERN = /(BASE_URL|URL|ENDPOINT|HOST)$/i;
|
| 10 |
const DOCKER_INSPECT_TIMEOUT_MS = 10_000;
|
| 11 |
|
|
@@ -53,7 +54,7 @@ export function summarizeEnvEntries(entries) {
|
|
| 53 |
|
| 54 |
function summarizeEnvEntry(name, value) {
|
| 55 |
const set = value.length > 0;
|
| 56 |
-
const sensitive = SECRET_NAME_PATTERN.test(name);
|
| 57 |
const summary = { name, set, sensitive, value_kind: classifyValue(value) };
|
| 58 |
if (sensitive && set) {
|
| 59 |
summary.item_count = value.split(',').map((item) => item.trim()).filter(Boolean).length;
|
|
|
|
| 6 |
|
| 7 |
const DEFAULT_ENV_FILES = ['.env.local', '.env.real-smoke.local', '.env.agent.local'];
|
| 8 |
const SECRET_NAME_PATTERN = /(API_?KEY|API_?KEYS|TOKEN|PASSWORD|SECRET|CREDENTIAL|PRIVATE)/i;
|
| 9 |
+
const PRIVATE_ENDPOINT_NAME_PATTERN = /PROXY/i;
|
| 10 |
const URL_NAME_PATTERN = /(BASE_URL|URL|ENDPOINT|HOST)$/i;
|
| 11 |
const DOCKER_INSPECT_TIMEOUT_MS = 10_000;
|
| 12 |
|
|
|
|
| 54 |
|
| 55 |
function summarizeEnvEntry(name, value) {
|
| 56 |
const set = value.length > 0;
|
| 57 |
+
const sensitive = SECRET_NAME_PATTERN.test(name) || PRIVATE_ENDPOINT_NAME_PATTERN.test(name);
|
| 58 |
const summary = { name, set, sensitive, value_kind: classifyValue(value) };
|
| 59 |
if (sensitive && set) {
|
| 60 |
summary.item_count = value.split(',').map((item) => item.trim()).filter(Boolean).length;
|
scripts/env-summary.test.mjs
CHANGED
|
@@ -17,6 +17,7 @@ describe('env-summary', () => {
|
|
| 17 |
'OPENAI_API_KEY=sk-real-looking-secret-value',
|
| 18 |
'OPENAI_CHANNEL_1_API_KEYS=key-one,key-two',
|
| 19 |
'OPENAI_API_BASE_URL=https://api.example.com/v1',
|
|
|
|
| 20 |
'REDIS_HOST=localhost:6379',
|
| 21 |
'PUBLIC_NAME=value # deployment note',
|
| 22 |
'ENABLE_STREAMING_BATCH=true',
|
|
@@ -30,6 +31,8 @@ describe('env-summary', () => {
|
|
| 30 |
assert.equal(serialized.includes('sk-real-looking-secret-value'), false);
|
| 31 |
assert.equal(serialized.includes('key-one'), false);
|
| 32 |
assert.equal(serialized.includes('key-two'), false);
|
|
|
|
|
|
|
| 33 |
assert.equal(serialized.includes('deployment note'), false);
|
| 34 |
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_API_KEY'), {
|
| 35 |
name: 'OPENAI_API_KEY',
|
|
@@ -39,6 +42,13 @@ describe('env-summary', () => {
|
|
| 39 |
item_count: 1
|
| 40 |
});
|
| 41 |
assert.equal(summary.find((item) => item.name === 'OPENAI_CHANNEL_1_API_KEYS')?.item_count, 2);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_API_BASE_URL')?.url, {
|
| 43 |
valid: true,
|
| 44 |
protocol: 'https',
|
|
|
|
| 17 |
'OPENAI_API_KEY=sk-real-looking-secret-value',
|
| 18 |
'OPENAI_CHANNEL_1_API_KEYS=key-one,key-two',
|
| 19 |
'OPENAI_API_BASE_URL=https://api.example.com/v1',
|
| 20 |
+
'OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal.example:8080',
|
| 21 |
'REDIS_HOST=localhost:6379',
|
| 22 |
'PUBLIC_NAME=value # deployment note',
|
| 23 |
'ENABLE_STREAMING_BATCH=true',
|
|
|
|
| 31 |
assert.equal(serialized.includes('sk-real-looking-secret-value'), false);
|
| 32 |
assert.equal(serialized.includes('key-one'), false);
|
| 33 |
assert.equal(serialized.includes('key-two'), false);
|
| 34 |
+
assert.equal(serialized.includes('proxy.internal.example'), false);
|
| 35 |
+
assert.equal(serialized.includes('8080'), false);
|
| 36 |
assert.equal(serialized.includes('deployment note'), false);
|
| 37 |
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_API_KEY'), {
|
| 38 |
name: 'OPENAI_API_KEY',
|
|
|
|
| 42 |
item_count: 1
|
| 43 |
});
|
| 44 |
assert.equal(summary.find((item) => item.name === 'OPENAI_CHANNEL_1_API_KEYS')?.item_count, 2);
|
| 45 |
+
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_UPSTREAM_PROXY_URL'), {
|
| 46 |
+
name: 'OPENAI_UPSTREAM_PROXY_URL',
|
| 47 |
+
set: true,
|
| 48 |
+
sensitive: true,
|
| 49 |
+
value_kind: 'url',
|
| 50 |
+
item_count: 1
|
| 51 |
+
});
|
| 52 |
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_API_BASE_URL')?.url, {
|
| 53 |
valid: true,
|
| 54 |
protocol: 'https',
|
scripts/smoke-image-upstream-compat.mjs
CHANGED
|
@@ -15,6 +15,7 @@ function configureRouteEnv() {
|
|
| 15 |
'APP_PASSWORD',
|
| 16 |
'OPENAI_API_KEY',
|
| 17 |
'OPENAI_API_BASE_URL',
|
|
|
|
| 18 |
'OPENAI_CHANNEL_1_API_KEYS',
|
| 19 |
'OPENAI_CHANNEL_1_BASE_URL',
|
| 20 |
'IMAGE_GENERATION_BACKEND',
|
|
|
|
| 15 |
'APP_PASSWORD',
|
| 16 |
'OPENAI_API_KEY',
|
| 17 |
'OPENAI_API_BASE_URL',
|
| 18 |
+
'OPENAI_UPSTREAM_PROXY_URL',
|
| 19 |
'OPENAI_CHANNEL_1_API_KEYS',
|
| 20 |
'OPENAI_CHANNEL_1_BASE_URL',
|
| 21 |
'IMAGE_GENERATION_BACKEND',
|
scripts/smoke-image-upstream-local-final-gate.mjs
CHANGED
|
@@ -187,6 +187,7 @@ function isSmokeEnvKey(key) {
|
|
| 187 |
key.startsWith('OPENAI_CHANNEL_') ||
|
| 188 |
key === 'OPENAI_API_BASE_URL' ||
|
| 189 |
key === 'OPENAI_API_KEY' ||
|
|
|
|
| 190 |
key === 'OPENAI_RESPONSES_API_MODEL' ||
|
| 191 |
key === 'OPENAI_ROUTING_STRATEGY' ||
|
| 192 |
key === 'OPENAI_CHANNELS_JSON' ||
|
|
|
|
| 187 |
key.startsWith('OPENAI_CHANNEL_') ||
|
| 188 |
key === 'OPENAI_API_BASE_URL' ||
|
| 189 |
key === 'OPENAI_API_KEY' ||
|
| 190 |
+
key === 'OPENAI_UPSTREAM_PROXY_URL' ||
|
| 191 |
key === 'OPENAI_RESPONSES_API_MODEL' ||
|
| 192 |
key === 'OPENAI_ROUTING_STRATEGY' ||
|
| 193 |
key === 'OPENAI_CHANNELS_JSON' ||
|
scripts/smoke-image-upstream-real.test.mjs
CHANGED
|
@@ -1025,6 +1025,7 @@ function isSmokeEnvKey(key) {
|
|
| 1025 |
key.startsWith('OPENAI_CHANNEL_') ||
|
| 1026 |
key === 'OPENAI_API_BASE_URL' ||
|
| 1027 |
key === 'OPENAI_API_KEY' ||
|
|
|
|
| 1028 |
key === 'OPENAI_RESPONSES_API_MODEL' ||
|
| 1029 |
key === 'OPENAI_ROUTING_STRATEGY' ||
|
| 1030 |
key === 'OPENAI_CHANNELS_JSON' ||
|
|
|
|
| 1025 |
key.startsWith('OPENAI_CHANNEL_') ||
|
| 1026 |
key === 'OPENAI_API_BASE_URL' ||
|
| 1027 |
key === 'OPENAI_API_KEY' ||
|
| 1028 |
+
key === 'OPENAI_UPSTREAM_PROXY_URL' ||
|
| 1029 |
key === 'OPENAI_RESPONSES_API_MODEL' ||
|
| 1030 |
key === 'OPENAI_ROUTING_STRATEGY' ||
|
| 1031 |
key === 'OPENAI_CHANNELS_JSON' ||
|
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
|
|
@@ -18,6 +18,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
|
|
| 18 |
- 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
|
| 19 |
- 查询当前实例内存中的渠道、凭证和请求方式健康状态:优先运行 `scripts/diagnose-channel-health.mjs`。它只读调用 Agent API,不触发上游探测或图片生成,也不能证明真实上游可用。
|
| 20 |
- 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。接入新上游渠道时,先确认 `/models` 和 `/images/generations` 能通,再用 `npm run smoke:image-upstream-real -- --allow-billable` 逐个验证 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。脚本也接受 request mode 别名 `images-json`、`images-sse`、`responses-json`、`responses-sse`,方便按通道能力筛选 case。只有内联 `b64_json`、Responses `result` 或与 API Base URL 同源的 artifact URL 才算可被本服务消费;远程 URL-only 结果不能写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果某一路径先返回 `object=image.task,status=pending`,说明该请求方式不是直接完成结果;应先确认同一业务键能否在同一渠道下重试拿到最终图片,再把可用的 `request_modes` 写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。服务端未配置 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 时按费用更少优先选择:`images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`;只有真实 smoke 证明需要改变顺序时,管理员才写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 或全局 `OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`。
|
|
|
|
| 21 |
- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
|
| 22 |
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
|
| 23 |
- 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
|
|
|
|
| 1 |
---
|
| 2 |
name: gpt-image-playground-agent
|
| 3 |
+
description: 当用户需要通过已部署的 GPT Image Playground 生成、编辑、批量生成、转换图片格式、查询结果反馈、渠道健康、诊断图片接口,或对新图片上游运行完整能力矩阵并生成私有渠道配置时使用;必须优先运行本 Skill 内置 scripts/generate-image.mjs、edit-image.mjs、batch-images.mjs、convert-image-format.mjs、diagnose-request.mjs、diagnose-channel-health.mjs、probe-upstream-image.mjs 或 channel-capability-matrix.mjs,而不是临时编写 API 调用脚本。
|
| 4 |
---
|
| 5 |
|
| 6 |
# GPT Image Playground Agent
|
|
|
|
| 18 |
- 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
|
| 19 |
- 查询当前实例内存中的渠道、凭证和请求方式健康状态:优先运行 `scripts/diagnose-channel-health.mjs`。它只读调用 Agent API,不触发上游探测或图片生成,也不能证明真实上游可用。
|
| 20 |
- 诊断上游图片接口:优先运行 `scripts/probe-upstream-image.mjs`。接入新上游渠道时,先确认 `/models` 和 `/images/generations` 能通,再用 `npm run smoke:image-upstream-real -- --allow-billable` 逐个验证 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse`。脚本也接受 request mode 别名 `images-json`、`images-sse`、`responses-json`、`responses-sse`,方便按通道能力筛选 case。只有内联 `b64_json`、Responses `result` 或与 API Base URL 同源的 artifact URL 才算可被本服务消费;远程 URL-only 结果不能写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果某一路径先返回 `object=image.task,status=pending`,说明该请求方式不是直接完成结果;应先确认同一业务键能否在同一渠道下重试拿到最终图片,再把可用的 `request_modes` 写入 `OPENAI_CHANNEL_N_REQUEST_MODES`。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从 `OPENAI_CHANNEL_N_REQUEST_MODES` 移除。服务端未配置 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 时按费用更少优先选择:`images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`;只有真实 smoke 证明需要改变顺序时,管理员才写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY` 或全局 `OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`。
|
| 21 |
+
- 对新上游完成固定四模式验证并准备可直接使用的私有配置:运行 `scripts/channel-capability-matrix.mjs`。只有用户明确允许计费时才传 `--allow-billable`;需要输出配置时再显式传 `--write-env-file <private-path>`。它固定串行验证 Images/Responses 的非流式和 SSE 模式,仅在 `/models` 通过、矩阵完整、至少一个方式返回可消费最终图且凭证有效时写入。生成文件只保留实际通过的 `OPENAI_CHANNEL_N_REQUEST_MODES`,显式设置匹配实测能力的 `IMAGE_GENERATION_BACKEND` 和 `IMAGE_STREAMING_STRATEGY=auto`;若没有任何 Images API 模式通过,则默认使用 `responses-image-generation`。Responses 模式通过时会同时启用 Responses 后端并写入实测顶层模型;远程明文 HTTP 目标会写入精确的 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`,使生成配置符合服务端安全门禁。输出文件是权限 `0600` 的独立私有 env 配置,默认拒绝覆盖和符号链接;脚本不合并或自动改写现有 `.env.local`,不会重启服务或部署。
|
| 22 |
- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
|
| 23 |
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
|
| 24 |
- 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
|
skills/gpt-image-playground-agent/agents/openai.yaml
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
interface:
|
| 2 |
display_name: "GPT Image Agent API"
|
| 3 |
-
short_description: "
|
| 4 |
-
default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;运行
|
|
|
|
| 1 |
interface:
|
| 2 |
display_name: "GPT Image Agent API"
|
| 3 |
+
short_description: "用内置脚本生成图片、诊断服务并验证上游渠道能力矩阵"
|
| 4 |
+
default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;新上游先运行能力矩阵,只有真实验证通过后才生成私有渠道配置,不要临时编写 API 调用脚本。"
|
skills/gpt-image-playground-agent/references/api.md
CHANGED
|
@@ -29,6 +29,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
|
|
| 29 |
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
|
| 30 |
- `scripts/diagnose-channel-health.mjs`:通过 capabilities 声明的 Agent 端点读取当前服务进程的渠道健康快照,支持 `--base-url` 和 `--output`。
|
| 31 |
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
|
|
|
| 32 |
|
| 33 |
生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。dry-run 输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。generate 可添加 `--check-remote` 做只读远端检查,输出 `verification_scope.mode=remote_contract_and_local_planning`,仅访问 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,不会发送真实生图请求。必须显式添加 `--allow-billable` 才会调用真实端点。generate 默认提交到 `/api/agent/image-requests` 服务端编排入口;`--agent`、`--job`、`--page-sse` 才会显式改用 `/api/agent/images/generate`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。
|
| 34 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
|
@@ -192,6 +193,16 @@ node "<skill-root>/scripts/batch-images.mjs" --allow-billable --input tasks.json
|
|
| 192 |
|
| 193 |
上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。base URL 必须是无凭据、无查询参数和无片段的 `http`/`https` 绝对 URL。输出不会包含 key,也不会输出完整 base64。
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
## 能力查询
|
| 196 |
|
| 197 |
```http
|
|
@@ -207,6 +218,7 @@ GET /api/agent/capabilities
|
|
| 207 |
- `image_transport.upstream_timeout_ms`:当前服务端图片上游请求超时,脚本未显式传 `--timeout-ms` 时会用它延长默认超时。
|
| 208 |
- `image_transport.stream_data_interval_timeout_ms`:已建立图片流的单次数据空闲超时;`0` 表示服务端禁用该空闲计时器。
|
| 209 |
- `image_transport.upstream_max_retries`:OpenAI SDK 图片请求自动重试次数;默认 `0`,避免长耗时图片请求被 SDK 自动重试后重复计费。
|
|
|
|
| 210 |
- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
|
| 211 |
- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
|
| 212 |
- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
|
|
@@ -233,6 +245,7 @@ GET /api/agent/capabilities
|
|
| 233 |
- `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
|
| 234 |
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
|
| 235 |
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要,包含该渠道有效 `request_modes` 和按白名单过滤后的 `request_mode_priority`。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
|
|
|
|
| 236 |
- `request_mode_controls`:管理员 request mode 白名单和优先级控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、`OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`、`OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`、默认低费用优先顺序、真实 smoke gate 和 `agent_client_policy=diagnostics_only`;Agent 客户端只能用于解释执行结果,不应据此自行选择上游请求方式。接入新渠道时,先用 `scripts/probe-upstream-image.mjs` 验证 `/models` 和 `/images/generations`,再用 `npm run smoke:image-upstream-real -- --allow-billable` 跑 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse` 之类的真实 smoke;也可用 `--case images-json`、`--case images-sse`、`--case responses-json`、`--case responses-sse` 按 request mode 筛选。脚本输出的 `request_modes.passed` 和顶层 `suggested_channel_config` 是写入 `OPENAI_CHANNEL_N_REQUEST_MODES` 的候选值;未通过、未实测、只返回远程 URL-only 或只返回 pending/poll_url 的 mode 不应写入。只有内联 `b64_json`、Responses `result` 或与 API Base URL 同源的 artifact URL 才算可被本服务消费。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从白名单里删掉,只保留通过的模式。需要覆盖默认排序时,再把通过的 mode 按期望顺序写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`。
|
| 237 |
- `providerManifests[].manifest.executionSupport`:`implemented` 表示当前执行器可按现有 Images/Responses 路径执行;`declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。pending/poll_url 只能作为诊断线索,不是可写入 request mode 白名单的通过证明。
|
| 238 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
|
@@ -273,6 +286,8 @@ GET /api/agent/capabilities
|
|
| 273 |
|
| 274 |
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `gpt-image-playground/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。
|
| 275 |
|
|
|
|
|
|
|
| 276 |
## Job Polling
|
| 277 |
|
| 278 |
```http
|
|
|
|
| 29 |
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
|
| 30 |
- `scripts/diagnose-channel-health.mjs`:通过 capabilities 声明的 Agent 端点读取当前服务进程的渠道健康快照,支持 `--base-url` 和 `--output`。
|
| 31 |
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
|
| 32 |
+
- `scripts/channel-capability-matrix.mjs`:固定串行验证四种上游图片请求方式,并在真实验证通过后生成私有渠道 env 配置。
|
| 33 |
|
| 34 |
生成、编辑和批量脚本默认只做 dry-run,不触发真实生图或编辑。dry-run 输出的 `verification_scope.mode=local_planning_only` 表示只完成本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。generate 可添加 `--check-remote` 做只读远端检查,输出 `verification_scope.mode=remote_contract_and_local_planning`,仅访问 `/api/agent/capabilities` 和 `/api/runtime-capabilities`,不会发送真实生图请求。必须显式添加 `--allow-billable` 才会调用真实端点。generate 默认提交到 `/api/agent/image-requests` 服务端编排入口;`--agent`、`--job`、`--page-sse` 才会显式改用 `/api/agent/images/generate`、`/api/agent/jobs/images/generate` 或页面端 `/api/images` SSE。
|
| 35 |
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
|
|
|
|
| 193 |
|
| 194 |
上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。base URL 必须是无凭据、无查询参数和无片段的 `http`/`https` 绝对 URL。输出不会包含 key,也不会输出完整 base64。
|
| 195 |
|
| 196 |
+
## 渠道能力矩阵和私有配置
|
| 197 |
+
|
| 198 |
+
```text
|
| 199 |
+
node "<skill-root>/scripts/channel-capability-matrix.mjs" --base-url https://upstream.example.com/v1 --responses-model gpt-5.4 --allow-billable --write-env-file /private/path/channel.env
|
| 200 |
+
```
|
| 201 |
+
|
| 202 |
+
该脚本固定串行调用 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`,不会把未测、失败、pending/poll 或远程 URL-only 结果写入渠道白名单。`--write-env-file` 必须与 `--allow-billable` 一起使用;写入还要求 `/models` 成功、四种模式都有报告、至少一个模式返回本服务可消费的最终图片、API Key 有效,且 Responses 模式有可用顶层模型。任何条件不满足时只输出脱敏矩阵报告,不创建目标文件。
|
| 203 |
+
|
| 204 |
+
写入的独立私有 env 配置包含 `OPENAI_CHANNEL_N_*`、实测通过的模式和优先级、`IMAGE_GENERATION_BACKEND`、`IMAGE_STREAMING_STRATEGY=auto`,以及需要时的 `ENABLE_RESPONSES_IMAGE_BACKEND` 和 `OPENAI_RESPONSES_API_MODEL`。只要至少一个 Images API 模式通过,默认后端为 `images-api`;只有 Responses 模式通过时,默认后端为 `responses-image-generation`,因此普通服务请求也会选择实际可用的协议。远程明文 HTTP 上游会额外写入精确的 `OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS`,以满足服务端对非 loopback HTTP 的安全门禁。目标文件使用原子写入和权限 `0600`,默认拒绝覆盖或符号链接;标准输出只提供脱敏 `configuration.env_preview`。脚本不会合并或自动写入 `.env.local`,不会重启服务或部署。
|
| 205 |
+
|
| 206 |
## 能力查询
|
| 207 |
|
| 208 |
```http
|
|
|
|
| 218 |
- `image_transport.upstream_timeout_ms`:当前服务端图片上游请求超时,脚本未显式传 `--timeout-ms` 时会用它延长默认超时。
|
| 219 |
- `image_transport.stream_data_interval_timeout_ms`:已建立图片流的单次数据空闲超时;`0` 表示服务端禁用该空闲计时器。
|
| 220 |
- `image_transport.upstream_max_retries`:OpenAI SDK 图片请求自动重试次数;默认 `0`,避免长耗时图片请求被 SDK 自动重试后重复计费。
|
| 221 |
+
- `image_transport.upstream_proxy`:全局服务端上游代理摘要,只包含 `configured` 和可选的 `protocol`(`http` 或 `https`);不会返回代理主机、端口、认证信息或完整 URL。代理由部署管理员通过 `OPENAI_UPSTREAM_PROXY_URL` 配置,只影响服务端到图片上游的出站连接。
|
| 222 |
- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
|
| 223 |
- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
|
| 224 |
- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
|
|
|
|
| 245 |
- `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
|
| 246 |
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
|
| 247 |
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要,包含该渠道有效 `request_modes` 和按白名单过滤后的 `request_mode_priority`。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
|
| 248 |
+
- `upstream_request_headers.channels[].upstream_proxy`:该渠道的有效上游代理摘要。`OPENAI_CHANNEL_N_PROXY_URL` 优先于 `OPENAI_UPSTREAM_PROXY_URL`;摘要只返回 `configured` 和 `protocol`,不返回代理地址或端口。
|
| 249 |
- `request_mode_controls`:管理员 request mode 白名单和优先级控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、`OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY`、`OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`、默认低费用优先顺序、真实 smoke gate 和 `agent_client_policy=diagnostics_only`;Agent 客户端只能用于解释执行结果,不应据此自行选择上游请求方式。接入新渠道时,先用 `scripts/probe-upstream-image.mjs` 验证 `/models` 和 `/images/generations`,再用 `npm run smoke:image-upstream-real -- --allow-billable` 跑 `original-images-json`、`sub2api-images-sse`、`sub2api-responses-json`、`gpt2image-responses-sse` 之类的真实 smoke;也可用 `--case images-json`、`--case images-sse`、`--case responses-json`、`--case responses-sse` 按 request mode 筛选。脚本输出的 `request_modes.passed` 和顶层 `suggested_channel_config` 是写入 `OPENAI_CHANNEL_N_REQUEST_MODES` 的候选值;未通过、未实测、只返回远程 URL-only 或只返回 pending/poll_url 的 mode 不应写入。只有内联 `b64_json`、Responses `result` 或与 API Base URL 同源的 artifact URL 才算可被本服务消费。如果 `/v1/responses` 返回 `403 Image generation is not enabled for this group`,或 HTTP 200 但只返回文本 output、没有 `image_generation_call.result`/`url`,就把对应 `responses-*` mode 从白名单里删掉,只保留通过的模式。需要覆盖默认排序时,再把通过的 mode 按期望顺序写入 `OPENAI_CHANNEL_N_REQUEST_MODE_PRIORITY`。
|
| 250 |
- `providerManifests[].manifest.executionSupport`:`implemented` 表示当前执行器可按现有 Images/Responses 路径执行;`declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。pending/poll_url 只能作为诊断线索,不是可写入 request mode 白名单的通过证明。
|
| 251 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
|
|
|
| 286 |
|
| 287 |
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `gpt-image-playground/<package-version>`;可用 `OPENAI_UPSTREAM_USER_AGENT` 或 `UPSTREAM_USER_AGENT` 覆盖全局 UA,也可用 `OPENAI_CHANNEL_N_USER_AGENT` 和 `OPENAI_CHANNEL_N_UPSTREAM_HEADERS_JSON` 覆盖单渠道安全 header。`Authorization`、`Accept`、`Content-Type`、`Content-Length` 和 `Host` 等协议头不可由 extra headers 覆盖;固定业务头和鉴权头始终由调用路径设置。
|
| 288 |
|
| 289 |
+
上游代理同样由服务端统一执行:`OPENAI_UPSTREAM_PROXY_URL` 为全局默认值,`OPENAI_CHANNEL_N_PROXY_URL` 可覆盖单个渠道。它们只接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS;配置变更需重启或重新部署服务。代理适用于服务端上游 API、SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志,不影响 Agent 客户端到 Playground 的连接。
|
| 290 |
+
|
| 291 |
## Job Polling
|
| 292 |
|
| 293 |
```http
|
skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import { spawn } from 'node:child_process';
|
| 3 |
+
import { closeSync, chmodSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
|
| 4 |
+
import { randomBytes } from 'node:crypto';
|
| 5 |
+
import { basename, dirname, join, resolve } from 'node:path';
|
| 6 |
+
import { fileURLToPath } from 'node:url';
|
| 7 |
+
import {
|
| 8 |
+
buildCapabilityMatrix,
|
| 9 |
+
buildChannelEnvConfig,
|
| 10 |
+
buildRedactedChannelEnvPreview,
|
| 11 |
+
createDefaultChannelId,
|
| 12 |
+
redactKnownSecrets,
|
| 13 |
+
resolveUpstreamApiKey,
|
| 14 |
+
validateChannelApiKey,
|
| 15 |
+
validateChannelId
|
| 16 |
+
} from './lib/channel-capability-matrix.mjs';
|
| 17 |
+
import {
|
| 18 |
+
errorMessage,
|
| 19 |
+
loadPrivateAgentEnvFile,
|
| 20 |
+
normalizeBaseUrl,
|
| 21 |
+
readConfiguredPositiveInteger,
|
| 22 |
+
readOptionValue
|
| 23 |
+
} from './lib/script-utils.mjs';
|
| 24 |
+
|
| 25 |
+
const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url));
|
| 26 |
+
const PROBE_SCRIPT_PATH = join(SCRIPT_DIRECTORY, 'probe-upstream-image.mjs');
|
| 27 |
+
const DEFAULT_UPSTREAM_BASE_URL = 'https://api.openai.com/v1';
|
| 28 |
+
|
| 29 |
+
loadPrivateAgentEnvFile();
|
| 30 |
+
|
| 31 |
+
let options;
|
| 32 |
+
try {
|
| 33 |
+
options = parseArgs(process.argv.slice(2));
|
| 34 |
+
if (options.help) {
|
| 35 |
+
printUsage();
|
| 36 |
+
process.exit(0);
|
| 37 |
+
}
|
| 38 |
+
validateOptions(options);
|
| 39 |
+
} catch (error) {
|
| 40 |
+
console.error(errorMessage(error));
|
| 41 |
+
printUsage();
|
| 42 |
+
process.exit(2);
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
try {
|
| 46 |
+
const baseUrl = normalizeBaseUrl(
|
| 47 |
+
options.baseUrl || process.env.GPT_IMAGE_UPSTREAM_BASE_URL || process.env.OPENAI_API_BASE_URL || DEFAULT_UPSTREAM_BASE_URL
|
| 48 |
+
);
|
| 49 |
+
const upstream = new URL(baseUrl);
|
| 50 |
+
const apiKey = resolveUpstreamApiKey();
|
| 51 |
+
const apiKeyValidation = validateChannelApiKey(apiKey.value);
|
| 52 |
+
const channelId = options.channelId || createDefaultChannelId(upstream.hostname, options.channelIndex);
|
| 53 |
+
const channelIdValidation = validateChannelId(channelId);
|
| 54 |
+
if (!channelIdValidation.ok) throw new Error('--channel-id 只能包含字母、数字、点、下划线和连字符,且长度不超过 64。');
|
| 55 |
+
if (options.writeEnvFile && !apiKeyValidation.ok) {
|
| 56 |
+
throw new Error('生成私有配置需要设置有效的 GPT_IMAGE_UPSTREAM_API_KEY 或 OPENAI_API_KEY。');
|
| 57 |
+
}
|
| 58 |
+
if (options.writeEnvFile) {
|
| 59 |
+
const target = inspectPrivateEnvTarget(options.writeEnvFile, options.overwrite);
|
| 60 |
+
if (!target.ok) throw new Error(target.message);
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
await runCapabilityMatrix({ baseUrl, upstream, apiKey, apiKeyValidation, channelId });
|
| 64 |
+
} catch (error) {
|
| 65 |
+
console.error(errorMessage(error));
|
| 66 |
+
process.exit(2);
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function parseArgs(argv) {
|
| 70 |
+
const parsed = {
|
| 71 |
+
baseUrl: undefined,
|
| 72 |
+
model: 'gpt-image-2',
|
| 73 |
+
responsesModel: undefined,
|
| 74 |
+
prompt: 'channel capability matrix probe',
|
| 75 |
+
size: '1024x1024',
|
| 76 |
+
quality: 'low',
|
| 77 |
+
format: 'webp',
|
| 78 |
+
outputCompression: undefined,
|
| 79 |
+
timeoutMs: undefined,
|
| 80 |
+
channelIndex: 1,
|
| 81 |
+
channelId: undefined,
|
| 82 |
+
writeEnvFile: undefined,
|
| 83 |
+
overwrite: false,
|
| 84 |
+
allowBillable: false,
|
| 85 |
+
help: false
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 89 |
+
const arg = argv[index];
|
| 90 |
+
if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
|
| 91 |
+
else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
|
| 92 |
+
else if (arg === '--responses-model') parsed.responsesModel = readOptionValue(argv, (index += 1), arg);
|
| 93 |
+
else if (arg === '--prompt') parsed.prompt = readOptionValue(argv, (index += 1), arg);
|
| 94 |
+
else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
|
| 95 |
+
else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
|
| 96 |
+
else if (arg === '--format' || arg === '--output-format') parsed.format = readOptionValue(argv, (index += 1), arg);
|
| 97 |
+
else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(argv, (index += 1), arg);
|
| 98 |
+
else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
|
| 99 |
+
else if (arg === '--channel-index') parsed.channelIndex = readOptionValue(argv, (index += 1), arg);
|
| 100 |
+
else if (arg === '--channel-id') parsed.channelId = readOptionValue(argv, (index += 1), arg);
|
| 101 |
+
else if (arg === '--write-env-file') parsed.writeEnvFile = readOptionValue(argv, (index += 1), arg);
|
| 102 |
+
else if (arg === '--overwrite') parsed.overwrite = true;
|
| 103 |
+
else if (arg === '--allow-billable') parsed.allowBillable = true;
|
| 104 |
+
else if (arg === '--help' || arg === '-h') parsed.help = true;
|
| 105 |
+
else throw new Error('包含未知参数。');
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
return parsed;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function validateOptions(parsed) {
|
| 112 |
+
if (!/^[1-9]\d*$/.test(String(parsed.channelIndex))) {
|
| 113 |
+
throw new Error('--channel-index 必须是正整数。');
|
| 114 |
+
}
|
| 115 |
+
parsed.channelIndex = Number(parsed.channelIndex);
|
| 116 |
+
if (!Number.isSafeInteger(parsed.channelIndex)) throw new Error('--channel-index 必须是正整数。');
|
| 117 |
+
if (parsed.overwrite && !parsed.writeEnvFile) throw new Error('--overwrite 必须和 --write-env-file 一起使用。');
|
| 118 |
+
if (parsed.writeEnvFile && !parsed.allowBillable) {
|
| 119 |
+
throw new Error('--write-env-file 需要同时使用 --allow-billable。');
|
| 120 |
+
}
|
| 121 |
+
if (parsed.timeoutMs !== undefined) readConfiguredPositiveInteger(parsed.timeoutMs, '--timeout-ms', 30000);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
async function runCapabilityMatrix(input) {
|
| 125 |
+
const probeResult = await runProbe(options, input.baseUrl);
|
| 126 |
+
const probeReport = parseProbeReport(probeResult.stdout);
|
| 127 |
+
const redactText = (value) => redactKnownSecrets(value, [input.apiKey.value]);
|
| 128 |
+
const matrix = buildCapabilityMatrix({
|
| 129 |
+
probeReport,
|
| 130 |
+
allowBillable: options.allowBillable,
|
| 131 |
+
apiKeyValid: input.apiKeyValidation.ok,
|
| 132 |
+
apiKeyError: input.apiKeyValidation.reason,
|
| 133 |
+
redactText
|
| 134 |
+
});
|
| 135 |
+
const configuration = {
|
| 136 |
+
...matrix.configuration,
|
| 137 |
+
channel_index: options.channelIndex,
|
| 138 |
+
channel_id: input.channelId,
|
| 139 |
+
...(matrix.configuration.ready
|
| 140 |
+
? {
|
| 141 |
+
env_preview: buildRedactedChannelEnvPreview({
|
| 142 |
+
channelIndex: options.channelIndex,
|
| 143 |
+
channelId: input.channelId,
|
| 144 |
+
baseUrl: input.baseUrl,
|
| 145 |
+
requestModes: matrix.configuration.request_modes,
|
| 146 |
+
requestModePriority: matrix.configuration.request_mode_priority,
|
| 147 |
+
responsesModel: matrix.configuration.responses_model
|
| 148 |
+
})
|
| 149 |
+
}
|
| 150 |
+
: {})
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
let write = { requested: Boolean(options.writeEnvFile), attempted: false, written: false, reason: 'not_requested' };
|
| 154 |
+
if (options.writeEnvFile) {
|
| 155 |
+
if (!configuration.ready) {
|
| 156 |
+
write = { requested: true, attempted: false, written: false, reason: 'configuration_not_ready' };
|
| 157 |
+
} else {
|
| 158 |
+
const content = buildChannelEnvConfig({
|
| 159 |
+
channelIndex: options.channelIndex,
|
| 160 |
+
channelId: input.channelId,
|
| 161 |
+
baseUrl: input.baseUrl,
|
| 162 |
+
apiKey: input.apiKey.value,
|
| 163 |
+
requestModes: configuration.request_modes,
|
| 164 |
+
requestModePriority: configuration.request_mode_priority,
|
| 165 |
+
responsesModel: configuration.responses_model
|
| 166 |
+
});
|
| 167 |
+
write = { requested: true, attempted: true, ...writePrivateEnvFile(options.writeEnvFile, content, options.overwrite) };
|
| 168 |
+
}
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
const report = {
|
| 172 |
+
ok: configuration.ready && (!options.writeEnvFile || write.written),
|
| 173 |
+
billable: options.allowBillable,
|
| 174 |
+
transport: 'channel_capability_matrix',
|
| 175 |
+
upstream: {
|
| 176 |
+
base_url: input.baseUrl,
|
| 177 |
+
host: input.upstream.host,
|
| 178 |
+
api_key_configured: Boolean(input.apiKey.value)
|
| 179 |
+
},
|
| 180 |
+
probe: {
|
| 181 |
+
completed: probeReport !== undefined,
|
| 182 |
+
exit_code: probeResult.exitCode,
|
| 183 |
+
stderr_present: probeResult.stderr.trim().length > 0
|
| 184 |
+
},
|
| 185 |
+
preflight: matrix.preflight,
|
| 186 |
+
matrix: {
|
| 187 |
+
requested: matrix.requested,
|
| 188 |
+
coverage_complete: matrix.coverage_complete,
|
| 189 |
+
fully_supported: matrix.fully_supported,
|
| 190 |
+
passed: matrix.passed,
|
| 191 |
+
failed: matrix.failed,
|
| 192 |
+
skipped: matrix.skipped,
|
| 193 |
+
modes: matrix.modes
|
| 194 |
+
},
|
| 195 |
+
configuration,
|
| 196 |
+
write,
|
| 197 |
+
summary: {
|
| 198 |
+
ok: configuration.ready && (!options.writeEnvFile || write.written),
|
| 199 |
+
billable: options.allowBillable,
|
| 200 |
+
request_modes: configuration.request_modes,
|
| 201 |
+
blocking_reasons: configuration.blocking_reasons,
|
| 202 |
+
next_action: readNextAction({ configuration, write, writeRequested: Boolean(options.writeEnvFile) })
|
| 203 |
+
}
|
| 204 |
+
};
|
| 205 |
+
|
| 206 |
+
console.log(JSON.stringify(redactKnownSecrets(report, [input.apiKey.value]), null, 2));
|
| 207 |
+
process.exitCode = report.ok ? 0 : 1;
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
async function runProbe(parsed, normalizedBaseUrl) {
|
| 211 |
+
const args = [
|
| 212 |
+
PROBE_SCRIPT_PATH,
|
| 213 |
+
'--base-url',
|
| 214 |
+
normalizedBaseUrl,
|
| 215 |
+
'--model',
|
| 216 |
+
parsed.model,
|
| 217 |
+
'--prompt',
|
| 218 |
+
parsed.prompt,
|
| 219 |
+
'--size',
|
| 220 |
+
parsed.size,
|
| 221 |
+
'--quality',
|
| 222 |
+
parsed.quality,
|
| 223 |
+
'--format',
|
| 224 |
+
parsed.format,
|
| 225 |
+
'--request-mode',
|
| 226 |
+
'all'
|
| 227 |
+
];
|
| 228 |
+
if (parsed.responsesModel) args.push('--responses-model', parsed.responsesModel);
|
| 229 |
+
if (parsed.outputCompression !== undefined) args.push('--output-compression', parsed.outputCompression);
|
| 230 |
+
if (parsed.timeoutMs !== undefined) args.push('--timeout-ms', parsed.timeoutMs);
|
| 231 |
+
if (parsed.allowBillable) args.push('--allow-billable');
|
| 232 |
+
|
| 233 |
+
return await new Promise((resolveResult) => {
|
| 234 |
+
const child = spawn(process.execPath, args, {
|
| 235 |
+
env: process.env,
|
| 236 |
+
stdio: ['ignore', 'pipe', 'pipe']
|
| 237 |
+
});
|
| 238 |
+
let stdout = '';
|
| 239 |
+
let stderr = '';
|
| 240 |
+
child.stdout.setEncoding('utf8');
|
| 241 |
+
child.stderr.setEncoding('utf8');
|
| 242 |
+
child.stdout.on('data', (chunk) => {
|
| 243 |
+
stdout += chunk;
|
| 244 |
+
});
|
| 245 |
+
child.stderr.on('data', (chunk) => {
|
| 246 |
+
stderr += chunk;
|
| 247 |
+
});
|
| 248 |
+
child.once('error', () => {
|
| 249 |
+
resolveResult({ exitCode: undefined, stdout, stderr });
|
| 250 |
+
});
|
| 251 |
+
child.once('close', (exitCode) => {
|
| 252 |
+
resolveResult({ exitCode: exitCode ?? undefined, stdout, stderr });
|
| 253 |
+
});
|
| 254 |
+
});
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function parseProbeReport(stdout) {
|
| 258 |
+
try {
|
| 259 |
+
const parsed = JSON.parse(stdout);
|
| 260 |
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
|
| 261 |
+
} catch {
|
| 262 |
+
return undefined;
|
| 263 |
+
}
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function inspectPrivateEnvTarget(targetPath, overwrite) {
|
| 267 |
+
const absoluteTarget = resolve(targetPath);
|
| 268 |
+
const targetDirectory = dirname(absoluteTarget);
|
| 269 |
+
const targetName = basename(absoluteTarget);
|
| 270 |
+
if (!targetName || targetName === '.') {
|
| 271 |
+
return { ok: false, message: '--write-env-file 必须指定常规文件路径。' };
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
try {
|
| 275 |
+
const directoryStat = statSync(targetDirectory);
|
| 276 |
+
if (!directoryStat.isDirectory()) {
|
| 277 |
+
return { ok: false, message: '--write-env-file 的父目录不可用。' };
|
| 278 |
+
}
|
| 279 |
+
} catch {
|
| 280 |
+
return { ok: false, message: '--write-env-file 的父目录不可用。' };
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
if (!existsSync(absoluteTarget)) return { ok: true };
|
| 284 |
+
try {
|
| 285 |
+
const targetStat = lstatSync(absoluteTarget);
|
| 286 |
+
if (targetStat.isSymbolicLink()) {
|
| 287 |
+
return { ok: false, message: '--write-env-file 不接受符号链接目标。' };
|
| 288 |
+
}
|
| 289 |
+
if (!targetStat.isFile()) {
|
| 290 |
+
return { ok: false, message: '--write-env-file 只能覆盖常规文件。' };
|
| 291 |
+
}
|
| 292 |
+
if (!overwrite) {
|
| 293 |
+
return { ok: false, message: '目标私有配置文件已存在;确认替换后显式添加 --overwrite。' };
|
| 294 |
+
}
|
| 295 |
+
return { ok: true };
|
| 296 |
+
} catch {
|
| 297 |
+
return { ok: false, message: '--write-env-file 目标不可用。' };
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function writePrivateEnvFile(targetPath, content, overwrite) {
|
| 302 |
+
const absoluteTarget = resolve(targetPath);
|
| 303 |
+
const targetDirectory = dirname(absoluteTarget);
|
| 304 |
+
const targetName = basename(absoluteTarget);
|
| 305 |
+
let temporaryPath;
|
| 306 |
+
|
| 307 |
+
try {
|
| 308 |
+
if (!targetName || targetName === '.') return { written: false, reason: 'invalid_target' };
|
| 309 |
+
const directoryStat = statSync(targetDirectory);
|
| 310 |
+
if (!directoryStat.isDirectory()) return { written: false, reason: 'invalid_target_directory' };
|
| 311 |
+
|
| 312 |
+
if (existsSync(absoluteTarget)) {
|
| 313 |
+
const targetStat = lstatSync(absoluteTarget);
|
| 314 |
+
if (targetStat.isSymbolicLink()) return { written: false, reason: 'target_is_symlink' };
|
| 315 |
+
if (!targetStat.isFile()) return { written: false, reason: 'target_not_regular_file' };
|
| 316 |
+
if (!overwrite) return { written: false, reason: 'target_exists' };
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
temporaryPath = join(targetDirectory, `.${targetName}.channel-capability-${process.pid}-${randomBytes(8).toString('hex')}.tmp`);
|
| 320 |
+
writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
|
| 321 |
+
chmodSync(temporaryPath, 0o600);
|
| 322 |
+
const descriptor = openSync(temporaryPath, 'r');
|
| 323 |
+
try {
|
| 324 |
+
fsyncSync(descriptor);
|
| 325 |
+
} finally {
|
| 326 |
+
closeSync(descriptor);
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
if (overwrite) {
|
| 330 |
+
renameSync(temporaryPath, absoluteTarget);
|
| 331 |
+
} else {
|
| 332 |
+
try {
|
| 333 |
+
linkSync(temporaryPath, absoluteTarget);
|
| 334 |
+
} catch (error) {
|
| 335 |
+
if (error && typeof error === 'object' && error.code === 'EEXIST') {
|
| 336 |
+
return { written: false, reason: 'target_exists' };
|
| 337 |
+
}
|
| 338 |
+
throw error;
|
| 339 |
+
}
|
| 340 |
+
unlinkSync(temporaryPath);
|
| 341 |
+
}
|
| 342 |
+
chmodSync(absoluteTarget, 0o600);
|
| 343 |
+
temporaryPath = undefined;
|
| 344 |
+
return { written: true, reason: 'written' };
|
| 345 |
+
} catch {
|
| 346 |
+
return { written: false, reason: 'write_failed' };
|
| 347 |
+
} finally {
|
| 348 |
+
if (temporaryPath && existsSync(temporaryPath)) unlinkSync(temporaryPath);
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function readNextAction(input) {
|
| 353 |
+
if (!input.configuration.ready) return 'inspect_capability_matrix';
|
| 354 |
+
if (input.writeRequested && !input.write.written) return 'resolve_private_config_write';
|
| 355 |
+
if (input.writeRequested) return 'apply_private_config_and_restart_explicitly';
|
| 356 |
+
return 'write_private_config_explicitly';
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
function printUsage() {
|
| 360 |
+
console.error('用法:channel-capability-matrix.mjs [options]');
|
| 361 |
+
console.error('固定串行验证 Images/Responses 的 JSON 与 SSE 四种请求方式。');
|
| 362 |
+
console.error('只有 --allow-billable 且至少一个方式返回可消费图片时,才允许写入私有渠道配置。');
|
| 363 |
+
console.error(
|
| 364 |
+
'常用参数:--base-url --model --responses-model --prompt --size --quality --format --output-compression --timeout-ms --channel-index --channel-id --allow-billable --write-env-file --overwrite'
|
| 365 |
+
);
|
| 366 |
+
console.error('不会自动写入 .env.local、重启服务或部署。API Key 仅从 GPT_IMAGE_UPSTREAM_API_KEY 或 OPENAI_API_KEY 读取。');
|
| 367 |
+
}
|
skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const CHANNEL_CAPABILITY_REQUEST_MODES = Object.freeze([
|
| 2 |
+
'images-non-stream',
|
| 3 |
+
'images-sse',
|
| 4 |
+
'responses-non-stream',
|
| 5 |
+
'responses-sse'
|
| 6 |
+
]);
|
| 7 |
+
|
| 8 |
+
export const DEFAULT_CHANNEL_CAPABILITY_REQUEST_MODE_PRIORITY = Object.freeze([
|
| 9 |
+
'images-non-stream',
|
| 10 |
+
'images-sse',
|
| 11 |
+
'responses-non-stream',
|
| 12 |
+
'responses-sse'
|
| 13 |
+
]);
|
| 14 |
+
|
| 15 |
+
const CONTROL_CHARACTER_PATTERN = /[\u0000\r\n]/;
|
| 16 |
+
const SAFE_UNQUOTED_ENV_VALUE_PATTERN = /^[A-Za-z0-9._/:,@%+=-]+$/;
|
| 17 |
+
|
| 18 |
+
export function resolveUpstreamApiKey(env = process.env) {
|
| 19 |
+
const preferred = readNonEmptyString(env.GPT_IMAGE_UPSTREAM_API_KEY);
|
| 20 |
+
if (preferred) return { value: preferred, source: 'GPT_IMAGE_UPSTREAM_API_KEY' };
|
| 21 |
+
|
| 22 |
+
const fallback = readNonEmptyString(env.OPENAI_API_KEY);
|
| 23 |
+
if (fallback) return { value: fallback, source: 'OPENAI_API_KEY' };
|
| 24 |
+
|
| 25 |
+
return { value: '', source: undefined };
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function validateChannelApiKey(value) {
|
| 29 |
+
if (!readNonEmptyString(value)) return { ok: false, reason: 'missing_api_key' };
|
| 30 |
+
if (CONTROL_CHARACTER_PATTERN.test(value)) return { ok: false, reason: 'invalid_api_key_characters' };
|
| 31 |
+
if (value.includes(',')) return { ok: false, reason: 'api_key_contains_comma' };
|
| 32 |
+
return { ok: true };
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export function validateChannelId(value) {
|
| 36 |
+
const normalized = readNonEmptyString(value);
|
| 37 |
+
if (!normalized) return { ok: false, reason: 'missing_channel_id' };
|
| 38 |
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(normalized)) {
|
| 39 |
+
return { ok: false, reason: 'invalid_channel_id' };
|
| 40 |
+
}
|
| 41 |
+
return { ok: true, value: normalized };
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
export function createDefaultChannelId(hostname, channelIndex) {
|
| 45 |
+
const hostPart = String(hostname || 'upstream')
|
| 46 |
+
.toLowerCase()
|
| 47 |
+
.replace(/[^a-z0-9]+/g, '-')
|
| 48 |
+
.replace(/^-+|-+$/g, '')
|
| 49 |
+
.slice(0, 48);
|
| 50 |
+
return `channel-${channelIndex}-${hostPart || 'upstream'}`;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
export function buildCapabilityMatrix(input) {
|
| 54 |
+
const report = isRecord(input.probeReport) ? input.probeReport : {};
|
| 55 |
+
const requestModes = isRecord(report.request_modes) ? report.request_modes : {};
|
| 56 |
+
const modeReports = isRecord(requestModes.modes) ? requestModes.modes : {};
|
| 57 |
+
const requested = Array.isArray(requestModes.requested) ? requestModes.requested : [];
|
| 58 |
+
const modes = {};
|
| 59 |
+
const passed = [];
|
| 60 |
+
const failed = [];
|
| 61 |
+
const skipped = [];
|
| 62 |
+
|
| 63 |
+
for (const requestMode of CHANNEL_CAPABILITY_REQUEST_MODES) {
|
| 64 |
+
const mode = isRecord(modeReports[requestMode]) ? modeReports[requestMode] : undefined;
|
| 65 |
+
const summary = summarizeMode(requestMode, mode, input.redactText);
|
| 66 |
+
modes[requestMode] = summary;
|
| 67 |
+
if (summary.status === 'passed') passed.push(requestMode);
|
| 68 |
+
else if (summary.status === 'skipped') skipped.push(requestMode);
|
| 69 |
+
else failed.push(requestMode);
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const models = summarizePreflight(report.models, input.redactText);
|
| 73 |
+
const coverageComplete =
|
| 74 |
+
CHANNEL_CAPABILITY_REQUEST_MODES.every((requestMode) => requested.includes(requestMode)) &&
|
| 75 |
+
CHANNEL_CAPABILITY_REQUEST_MODES.every((requestMode) => isRecord(modeReports[requestMode]));
|
| 76 |
+
const responsesModes = passed.filter((requestMode) => requestMode.startsWith('responses-'));
|
| 77 |
+
const responsesModel = readResponsesModel(modeReports, responsesModes);
|
| 78 |
+
const imageBackend = resolveDefaultImageBackend(passed);
|
| 79 |
+
const blockingReasons = [];
|
| 80 |
+
|
| 81 |
+
if (!input.allowBillable) blockingReasons.push('billable_verification_required');
|
| 82 |
+
if (!models.ok) blockingReasons.push('models_preflight_failed');
|
| 83 |
+
if (!coverageComplete) blockingReasons.push('incomplete_request_mode_matrix');
|
| 84 |
+
if (!input.apiKeyValid) blockingReasons.push(input.apiKeyError || 'missing_api_key');
|
| 85 |
+
if (passed.length === 0) blockingReasons.push('no_consumable_image_mode');
|
| 86 |
+
if (responsesModes.length > 0 && !responsesModel) blockingReasons.push('missing_responses_model');
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
requested: [...CHANNEL_CAPABILITY_REQUEST_MODES],
|
| 90 |
+
coverage_complete: coverageComplete,
|
| 91 |
+
fully_supported: passed.length === CHANNEL_CAPABILITY_REQUEST_MODES.length,
|
| 92 |
+
passed,
|
| 93 |
+
failed,
|
| 94 |
+
skipped,
|
| 95 |
+
modes,
|
| 96 |
+
preflight: {
|
| 97 |
+
dns: summarizePreflight(report.dns, input.redactText),
|
| 98 |
+
tls: summarizePreflight(report.tls, input.redactText),
|
| 99 |
+
models
|
| 100 |
+
},
|
| 101 |
+
configuration: {
|
| 102 |
+
ready: blockingReasons.length === 0,
|
| 103 |
+
blocking_reasons: blockingReasons,
|
| 104 |
+
request_modes: passed,
|
| 105 |
+
request_mode_priority: orderRequestModesByDefaultPriority(passed),
|
| 106 |
+
...(imageBackend ? { image_backend: imageBackend, streaming_strategy: 'auto' } : {}),
|
| 107 |
+
responses_backend_required: responsesModes.length > 0,
|
| 108 |
+
responses_model: responsesModel || undefined
|
| 109 |
+
}
|
| 110 |
+
};
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export function buildChannelEnvConfig(input) {
|
| 114 |
+
const channelIndex = readPositiveChannelIndex(input.channelIndex);
|
| 115 |
+
const channelId = readRequiredEnvValue(input.channelId, 'channel_id');
|
| 116 |
+
const baseUrl = readRequiredEnvValue(input.baseUrl, 'base_url');
|
| 117 |
+
const apiKey = readRequiredEnvValue(input.apiKey, 'api_key');
|
| 118 |
+
const requestModes = normalizeRequestModes(input.requestModes);
|
| 119 |
+
const requestModePriority = normalizeRequestModes(input.requestModePriority || requestModes);
|
| 120 |
+
const responsesModel = readNonEmptyString(input.responsesModel);
|
| 121 |
+
const hasResponsesMode = requestModes.some((requestMode) => requestMode.startsWith('responses-'));
|
| 122 |
+
const imageBackend = resolveDefaultImageBackend(requestModes);
|
| 123 |
+
const plainHttpAllowlistValue = resolvePlainHttpAllowlistValue(baseUrl);
|
| 124 |
+
|
| 125 |
+
if (hasResponsesMode && !responsesModel) {
|
| 126 |
+
throw new Error('missing_responses_model');
|
| 127 |
+
}
|
| 128 |
+
if (!imageBackend) throw new Error('missing_image_backend');
|
| 129 |
+
|
| 130 |
+
const prefix = `OPENAI_CHANNEL_${channelIndex}`;
|
| 131 |
+
const lines = [
|
| 132 |
+
'# Generated after a billable upstream capability matrix probe.',
|
| 133 |
+
'# This file contains credentials. Keep it private and do not commit it.',
|
| 134 |
+
`${prefix}_ID=${serializeEnvValue(channelId)}`,
|
| 135 |
+
`${prefix}_BASE_URL=${serializeEnvValue(baseUrl)}`,
|
| 136 |
+
`${prefix}_API_KEYS=${serializeEnvValue(apiKey)}`,
|
| 137 |
+
`${prefix}_REQUEST_MODES=${serializeEnvValue(requestModes.join(','))}`,
|
| 138 |
+
`${prefix}_REQUEST_MODE_PRIORITY=${serializeEnvValue(requestModePriority.join(','))}`,
|
| 139 |
+
`IMAGE_GENERATION_BACKEND=${serializeEnvValue(imageBackend)}`,
|
| 140 |
+
'IMAGE_STREAMING_STRATEGY=auto'
|
| 141 |
+
];
|
| 142 |
+
|
| 143 |
+
if (plainHttpAllowlistValue) {
|
| 144 |
+
lines.push(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${serializeEnvValue(plainHttpAllowlistValue)}`);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
if (hasResponsesMode) {
|
| 148 |
+
lines.push('ENABLE_RESPONSES_IMAGE_BACKEND=true');
|
| 149 |
+
lines.push(`OPENAI_RESPONSES_API_MODEL=${serializeEnvValue(responsesModel)}`);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
return `${lines.join('\n')}\n`;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
export function buildRedactedChannelEnvPreview(input) {
|
| 156 |
+
const channelIndex = readPositiveChannelIndex(input.channelIndex);
|
| 157 |
+
const requestModes = normalizeRequestModes(input.requestModes);
|
| 158 |
+
const requestModePriority = normalizeRequestModes(input.requestModePriority || requestModes);
|
| 159 |
+
const imageBackend = resolveDefaultImageBackend(requestModes);
|
| 160 |
+
const baseUrl = readRequiredEnvValue(input.baseUrl, 'base_url');
|
| 161 |
+
const plainHttpAllowlistValue = resolvePlainHttpAllowlistValue(baseUrl);
|
| 162 |
+
if (!imageBackend) throw new Error('missing_image_backend');
|
| 163 |
+
const prefix = `OPENAI_CHANNEL_${channelIndex}`;
|
| 164 |
+
const lines = [
|
| 165 |
+
`${prefix}_ID=${serializeEnvValue(readRequiredEnvValue(input.channelId, 'channel_id'))}`,
|
| 166 |
+
`${prefix}_BASE_URL=${serializeEnvValue(baseUrl)}`,
|
| 167 |
+
`${prefix}_API_KEYS=[redacted]`,
|
| 168 |
+
`${prefix}_REQUEST_MODES=${serializeEnvValue(requestModes.join(','))}`,
|
| 169 |
+
`${prefix}_REQUEST_MODE_PRIORITY=${serializeEnvValue(requestModePriority.join(','))}`,
|
| 170 |
+
`IMAGE_GENERATION_BACKEND=${serializeEnvValue(imageBackend)}`,
|
| 171 |
+
'IMAGE_STREAMING_STRATEGY=auto'
|
| 172 |
+
];
|
| 173 |
+
|
| 174 |
+
if (plainHttpAllowlistValue) {
|
| 175 |
+
lines.push(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${serializeEnvValue(plainHttpAllowlistValue)}`);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
if (requestModes.some((requestMode) => requestMode.startsWith('responses-'))) {
|
| 179 |
+
lines.push('ENABLE_RESPONSES_IMAGE_BACKEND=true');
|
| 180 |
+
lines.push(`OPENAI_RESPONSES_API_MODEL=${serializeEnvValue(readRequiredEnvValue(input.responsesModel, 'responses_model'))}`);
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
return lines;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
export function redactKnownSecrets(value, secrets) {
|
| 187 |
+
const normalizedSecrets = Array.from(new Set(secrets.map(readNonEmptyString).filter(Boolean)));
|
| 188 |
+
if (typeof value === 'string') {
|
| 189 |
+
return normalizedSecrets.reduce((result, secret) => result.split(secret).join('[redacted]'), value);
|
| 190 |
+
}
|
| 191 |
+
if (Array.isArray(value)) return value.map((item) => redactKnownSecrets(item, normalizedSecrets));
|
| 192 |
+
if (!isRecord(value)) return value;
|
| 193 |
+
return Object.fromEntries(
|
| 194 |
+
Object.entries(value).map(([key, nestedValue]) => [key, redactKnownSecrets(nestedValue, normalizedSecrets)])
|
| 195 |
+
);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
function summarizeMode(requestMode, mode, redactText) {
|
| 199 |
+
if (!mode) {
|
| 200 |
+
return {
|
| 201 |
+
request_mode: requestMode,
|
| 202 |
+
status: 'failed',
|
| 203 |
+
ok: false,
|
| 204 |
+
billable: false,
|
| 205 |
+
reason: 'not_reported'
|
| 206 |
+
};
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const skipped = mode.skipped === true;
|
| 210 |
+
const passed = mode.ok === true && !skipped && mode.billable === true;
|
| 211 |
+
const status = skipped ? 'skipped' : passed ? 'passed' : 'failed';
|
| 212 |
+
const result = {
|
| 213 |
+
request_mode: requestMode,
|
| 214 |
+
status,
|
| 215 |
+
ok: mode.ok === true,
|
| 216 |
+
billable: mode.billable === true,
|
| 217 |
+
...(skipped ? { skipped: true } : {}),
|
| 218 |
+
...(readSafeInteger(mode.status) !== undefined ? { upstream_status: mode.status } : {}),
|
| 219 |
+
...(readSafeInteger(mode.elapsed_ms) !== undefined ? { elapsed_ms: mode.elapsed_ms } : {}),
|
| 220 |
+
...(readNonEmptyString(mode.category) ? { category: redact(mode.category, redactText) } : {}),
|
| 221 |
+
...(readNonEmptyString(mode.reason) ? { reason: redact(mode.reason, redactText) } : {}),
|
| 222 |
+
...(readErrorText(mode.error) ? { error: redact(readErrorText(mode.error), redactText) } : {}),
|
| 223 |
+
...(mode.has_consumable_image === true ? { has_consumable_image: true } : {}),
|
| 224 |
+
...(mode.has_remote_url_result === true ? { has_remote_url_result: true } : {}),
|
| 225 |
+
...(mode.has_same_origin_url_result === true ? { has_same_origin_url_result: true } : {})
|
| 226 |
+
};
|
| 227 |
+
return result;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function summarizePreflight(value, redactText) {
|
| 231 |
+
if (!isRecord(value)) return { ok: false, reason: 'not_reported' };
|
| 232 |
+
return {
|
| 233 |
+
ok: value.ok === true,
|
| 234 |
+
...(value.skipped === true ? { skipped: true } : {}),
|
| 235 |
+
...(readSafeInteger(value.status) !== undefined ? { status: value.status } : {}),
|
| 236 |
+
...(readSafeInteger(value.elapsed_ms) !== undefined ? { elapsed_ms: value.elapsed_ms } : {}),
|
| 237 |
+
...(readNonEmptyString(value.reason) ? { reason: redact(value.reason, redactText) } : {}),
|
| 238 |
+
...(readErrorText(value.error) ? { error: redact(readErrorText(value.error), redactText) } : {})
|
| 239 |
+
};
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function readResponsesModel(modeReports, responsesModes) {
|
| 243 |
+
for (const requestMode of responsesModes) {
|
| 244 |
+
const model = readNonEmptyString(modeReports[requestMode]?.responses_model);
|
| 245 |
+
if (model) return model;
|
| 246 |
+
}
|
| 247 |
+
return '';
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
function orderRequestModesByDefaultPriority(requestModes) {
|
| 251 |
+
const allowed = new Set(requestModes);
|
| 252 |
+
return DEFAULT_CHANNEL_CAPABILITY_REQUEST_MODE_PRIORITY.filter((requestMode) => allowed.has(requestMode));
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
function resolveDefaultImageBackend(requestModes) {
|
| 256 |
+
if (requestModes.some((requestMode) => requestMode.startsWith('images-'))) return 'images-api';
|
| 257 |
+
if (requestModes.some((requestMode) => requestMode.startsWith('responses-'))) return 'responses-image-generation';
|
| 258 |
+
return undefined;
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
function resolvePlainHttpAllowlistValue(baseUrl) {
|
| 262 |
+
const parsed = new URL(baseUrl);
|
| 263 |
+
if (parsed.protocol !== 'http:' || isLoopbackHostname(parsed.hostname)) return undefined;
|
| 264 |
+
return baseUrl;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
function isLoopbackHostname(hostname) {
|
| 268 |
+
const normalized = hostname.toLowerCase();
|
| 269 |
+
return (
|
| 270 |
+
normalized === 'localhost' ||
|
| 271 |
+
normalized === '::1' ||
|
| 272 |
+
normalized === '[::1]' ||
|
| 273 |
+
/^127(?:\.\d{1,3}){3}$/.test(normalized)
|
| 274 |
+
);
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
function normalizeRequestModes(value) {
|
| 278 |
+
const modes = Array.isArray(value) ? value : [];
|
| 279 |
+
const normalized = CHANNEL_CAPABILITY_REQUEST_MODES.filter((requestMode) => modes.includes(requestMode));
|
| 280 |
+
if (normalized.length === 0) throw new Error('missing_request_modes');
|
| 281 |
+
return normalized;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
function readPositiveChannelIndex(value) {
|
| 285 |
+
const text = String(value ?? '').trim();
|
| 286 |
+
if (!/^[1-9]\d*$/.test(text)) throw new Error('invalid_channel_index');
|
| 287 |
+
const parsed = Number(text);
|
| 288 |
+
if (!Number.isSafeInteger(parsed)) throw new Error('invalid_channel_index');
|
| 289 |
+
return parsed;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function readRequiredEnvValue(value, label) {
|
| 293 |
+
const normalized = readNonEmptyString(value);
|
| 294 |
+
if (!normalized || CONTROL_CHARACTER_PATTERN.test(normalized)) throw new Error(`invalid_${label}`);
|
| 295 |
+
return normalized;
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function serializeEnvValue(value) {
|
| 299 |
+
const normalized = readRequiredEnvValue(value, 'env_value');
|
| 300 |
+
return SAFE_UNQUOTED_ENV_VALUE_PATTERN.test(normalized) ? normalized : JSON.stringify(normalized);
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
function readSafeInteger(value) {
|
| 304 |
+
return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function readErrorText(value) {
|
| 308 |
+
if (typeof value === 'string') return value;
|
| 309 |
+
if (isRecord(value) && typeof value.message === 'string') return value.message;
|
| 310 |
+
return '';
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function redact(value, redactText) {
|
| 314 |
+
return typeof redactText === 'function' ? redactText(value) : value;
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
function readNonEmptyString(value) {
|
| 318 |
+
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function isRecord(value) {
|
| 322 |
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
| 323 |
+
}
|
src/app/api/agent/agent-routes.test.ts
CHANGED
|
@@ -59,6 +59,7 @@ beforeEach(async () => {
|
|
| 59 |
delete process.env.OPENAI_API_BASE_URL;
|
| 60 |
delete process.env.OPENAI_UPSTREAM_REQUEST_MODES;
|
| 61 |
delete process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY;
|
|
|
|
| 62 |
delete process.env.OPENAI_UPSTREAM_USER_AGENT;
|
| 63 |
delete process.env.UPSTREAM_USER_AGENT;
|
| 64 |
delete process.env.OPENAI_CHANNEL_1_ID;
|
|
@@ -157,6 +158,7 @@ describe('Agent route integration', () => {
|
|
| 157 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 158 |
{
|
| 159 |
id: 'matsca',
|
|
|
|
| 160 |
request_modes: ['images-non-stream'],
|
| 161 |
request_mode_priority: ['images-non-stream'],
|
| 162 |
request_headers: {
|
|
@@ -211,6 +213,7 @@ describe('Agent route integration', () => {
|
|
| 211 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 212 |
{
|
| 213 |
id: 'default',
|
|
|
|
| 214 |
request_modes: ['images-non-stream', 'images-sse'],
|
| 215 |
request_mode_priority: ['images-sse', 'images-non-stream'],
|
| 216 |
request_headers: {
|
|
@@ -223,6 +226,40 @@ describe('Agent route integration', () => {
|
|
| 223 |
]);
|
| 224 |
});
|
| 225 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
it('registers cleanup-managed artifacts for every request mode', async () => {
|
| 227 |
const { generateImage } = await loadAgentRoutes();
|
| 228 |
const { getAgentStateStore } = await import('@/lib/agent-state-runtime');
|
|
@@ -389,6 +426,7 @@ describe('Agent route integration', () => {
|
|
| 389 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 390 |
{
|
| 391 |
id: 'matsca',
|
|
|
|
| 392 |
request_modes: ['images-non-stream'],
|
| 393 |
request_mode_priority: ['images-non-stream'],
|
| 394 |
request_headers: {
|
|
|
|
| 59 |
delete process.env.OPENAI_API_BASE_URL;
|
| 60 |
delete process.env.OPENAI_UPSTREAM_REQUEST_MODES;
|
| 61 |
delete process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY;
|
| 62 |
+
delete process.env.OPENAI_UPSTREAM_PROXY_URL;
|
| 63 |
delete process.env.OPENAI_UPSTREAM_USER_AGENT;
|
| 64 |
delete process.env.UPSTREAM_USER_AGENT;
|
| 65 |
delete process.env.OPENAI_CHANNEL_1_ID;
|
|
|
|
| 158 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 159 |
{
|
| 160 |
id: 'matsca',
|
| 161 |
+
upstream_proxy: { configured: false },
|
| 162 |
request_modes: ['images-non-stream'],
|
| 163 |
request_mode_priority: ['images-non-stream'],
|
| 164 |
request_headers: {
|
|
|
|
| 213 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 214 |
{
|
| 215 |
id: 'default',
|
| 216 |
+
upstream_proxy: { configured: false },
|
| 217 |
request_modes: ['images-non-stream', 'images-sse'],
|
| 218 |
request_mode_priority: ['images-sse', 'images-non-stream'],
|
| 219 |
request_headers: {
|
|
|
|
| 226 |
]);
|
| 227 |
});
|
| 228 |
|
| 229 |
+
it('reports global and per-channel upstream proxy summaries without exposing endpoints', async () => {
|
| 230 |
+
const { getCapabilities } = await loadAgentRoutes();
|
| 231 |
+
process.env.OPENAI_UPSTREAM_PROXY_URL = 'https://global-proxy.integration.example:9443';
|
| 232 |
+
process.env.OPENAI_CHANNEL_1_ID = 'primary';
|
| 233 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://primary.example.com/v1';
|
| 234 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'primary-secret';
|
| 235 |
+
process.env.OPENAI_CHANNEL_2_ID = 'backup';
|
| 236 |
+
process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://backup.example.com/v1';
|
| 237 |
+
process.env.OPENAI_CHANNEL_2_API_KEYS = 'backup-secret';
|
| 238 |
+
process.env.OPENAI_CHANNEL_2_PROXY_URL = 'http://channel-proxy.integration.example:8080';
|
| 239 |
+
|
| 240 |
+
const response = await getCapabilities();
|
| 241 |
+
assert.equal(response.status, 200);
|
| 242 |
+
const body = await response.json();
|
| 243 |
+
assert.deepEqual(body.image_transport.upstream_proxy, { configured: true, protocol: 'https' });
|
| 244 |
+
assert.deepEqual(
|
| 245 |
+
body.upstream_request_headers.channels.map((channel: { id: string; upstream_proxy: unknown }) => ({
|
| 246 |
+
id: channel.id,
|
| 247 |
+
upstream_proxy: channel.upstream_proxy
|
| 248 |
+
})),
|
| 249 |
+
[
|
| 250 |
+
{ id: 'primary', upstream_proxy: { configured: true, protocol: 'https' } },
|
| 251 |
+
{ id: 'backup', upstream_proxy: { configured: true, protocol: 'http' } }
|
| 252 |
+
]
|
| 253 |
+
);
|
| 254 |
+
const serialized = JSON.stringify(body);
|
| 255 |
+
assert.equal(serialized.includes('global-proxy.integration.example'), false);
|
| 256 |
+
assert.equal(serialized.includes('channel-proxy.integration.example'), false);
|
| 257 |
+
assert.equal(serialized.includes('9443'), false);
|
| 258 |
+
assert.equal(serialized.includes('8080'), false);
|
| 259 |
+
assert.equal(serialized.includes('primary-secret'), false);
|
| 260 |
+
assert.equal(serialized.includes('backup-secret'), false);
|
| 261 |
+
});
|
| 262 |
+
|
| 263 |
it('registers cleanup-managed artifacts for every request mode', async () => {
|
| 264 |
const { generateImage } = await loadAgentRoutes();
|
| 265 |
const { getAgentStateStore } = await import('@/lib/agent-state-runtime');
|
|
|
|
| 426 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 427 |
{
|
| 428 |
id: 'matsca',
|
| 429 |
+
upstream_proxy: { configured: false },
|
| 430 |
request_modes: ['images-non-stream'],
|
| 431 |
request_mode_priority: ['images-non-stream'],
|
| 432 |
request_headers: {
|
src/app/api/agent/capabilities/route.ts
CHANGED
|
@@ -31,6 +31,7 @@ function readPublicCapabilitiesEnv(): Record<string, string | undefined> {
|
|
| 31 |
OPENAI_UPSTREAM_PROFILE: process.env.OPENAI_UPSTREAM_PROFILE,
|
| 32 |
OPENAI_UPSTREAM_REQUEST_MODES: process.env.OPENAI_UPSTREAM_REQUEST_MODES,
|
| 33 |
OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY,
|
|
|
|
| 34 |
OPENAI_UPSTREAM_USER_AGENT: readConfiguredMarker(process.env.OPENAI_UPSTREAM_USER_AGENT),
|
| 35 |
UPSTREAM_USER_AGENT: readConfiguredMarker(process.env.UPSTREAM_USER_AGENT),
|
| 36 |
OPENAI_ROUTING_STRATEGY: process.env.OPENAI_ROUTING_STRATEGY,
|
|
@@ -46,7 +47,7 @@ function readPublicChannelEnv(env: NodeJS.ProcessEnv): Record<string, string | u
|
|
| 46 |
const publicEnv: Record<string, string | undefined> = {};
|
| 47 |
for (const key of Object.keys(env)) {
|
| 48 |
const match =
|
| 49 |
-
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|UPSTREAM_PROFILE|PROVIDER_MANIFEST|REQUEST_MODES|REQUEST_MODE_PRIORITY|API_KEYS|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON)$/.exec(
|
| 50 |
key
|
| 51 |
);
|
| 52 |
if (!match) continue;
|
|
|
|
| 31 |
OPENAI_UPSTREAM_PROFILE: process.env.OPENAI_UPSTREAM_PROFILE,
|
| 32 |
OPENAI_UPSTREAM_REQUEST_MODES: process.env.OPENAI_UPSTREAM_REQUEST_MODES,
|
| 33 |
OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY,
|
| 34 |
+
OPENAI_UPSTREAM_PROXY_URL: process.env.OPENAI_UPSTREAM_PROXY_URL,
|
| 35 |
OPENAI_UPSTREAM_USER_AGENT: readConfiguredMarker(process.env.OPENAI_UPSTREAM_USER_AGENT),
|
| 36 |
UPSTREAM_USER_AGENT: readConfiguredMarker(process.env.UPSTREAM_USER_AGENT),
|
| 37 |
OPENAI_ROUTING_STRATEGY: process.env.OPENAI_ROUTING_STRATEGY,
|
|
|
|
| 47 |
const publicEnv: Record<string, string | undefined> = {};
|
| 48 |
for (const key of Object.keys(env)) {
|
| 49 |
const match =
|
| 50 |
+
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|PROXY_URL|UPSTREAM_PROFILE|PROVIDER_MANIFEST|REQUEST_MODES|REQUEST_MODE_PRIORITY|API_KEYS|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON)$/.exec(
|
| 51 |
key
|
| 52 |
);
|
| 53 |
if (!match) continue;
|
src/app/api/agent/diagnostics/channel-health/route.test.ts
CHANGED
|
@@ -19,6 +19,8 @@ beforeEach(() => {
|
|
| 19 |
process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://images.example.test/v1';
|
| 20 |
process.env.OPENAI_CHANNEL_1_API_KEYS = 'health-channel-secret,secondary-secret';
|
| 21 |
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream,images-sse';
|
|
|
|
|
|
|
| 22 |
resetServerChannelStateForTests();
|
| 23 |
});
|
| 24 |
|
|
@@ -104,6 +106,7 @@ describe('GET /api/agent/diagnostics/channel-health', () => {
|
|
| 104 |
channels: [
|
| 105 |
{
|
| 106 |
channel_id: 'primary',
|
|
|
|
| 107 |
credential_count: 2,
|
| 108 |
healthy_credential_count: 1,
|
| 109 |
unhealthy_credential_count: 1,
|
|
|
|
| 19 |
process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://images.example.test/v1';
|
| 20 |
process.env.OPENAI_CHANNEL_1_API_KEYS = 'health-channel-secret,secondary-secret';
|
| 21 |
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream,images-sse';
|
| 22 |
+
delete process.env.OPENAI_UPSTREAM_PROXY_URL;
|
| 23 |
+
delete process.env.OPENAI_CHANNEL_1_PROXY_URL;
|
| 24 |
resetServerChannelStateForTests();
|
| 25 |
});
|
| 26 |
|
|
|
|
| 106 |
channels: [
|
| 107 |
{
|
| 108 |
channel_id: 'primary',
|
| 109 |
+
upstream_proxy: { configured: false },
|
| 110 |
credential_count: 2,
|
| 111 |
healthy_credential_count: 1,
|
| 112 |
unhealthy_credential_count: 1,
|
src/app/api/agent/diagnostics/channel-health/route.ts
CHANGED
|
@@ -33,6 +33,7 @@ function toPublicChannelHealthSnapshot(snapshot: ChannelHealthSnapshot) {
|
|
| 33 |
observed_at: snapshot.at,
|
| 34 |
channels: snapshot.channels.map((channel) => ({
|
| 35 |
channel_id: channel.channelId,
|
|
|
|
| 36 |
credential_count: channel.credentialCount,
|
| 37 |
healthy_credential_count: channel.healthyCredentialCount,
|
| 38 |
unhealthy_credential_count: channel.unhealthyCredentialCount,
|
|
|
|
| 33 |
observed_at: snapshot.at,
|
| 34 |
channels: snapshot.channels.map((channel) => ({
|
| 35 |
channel_id: channel.channelId,
|
| 36 |
+
upstream_proxy: channel.upstreamProxy,
|
| 37 |
credential_count: channel.credentialCount,
|
| 38 |
healthy_credential_count: channel.healthyCredentialCount,
|
| 39 |
unhealthy_credential_count: channel.unhealthyCredentialCount,
|
src/app/api/deploy-marker/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
-
const deployMarker = {"schema_version":1,"local_sha":"
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
|
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
+
const deployMarker = {"schema_version":1,"local_sha":"4770498acf64bbcda373c6a220f5b5781cef6136","created_at":"2026-07-25T04:50:43.340Z","deploy_id":"28984fcf-867f-419f-bc4c-22acd7ba92c9"} as const;
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
src/app/api/images/route-defaults.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
| 2 |
PNG_BASE64,
|
| 3 |
imageFormRequest,
|
| 4 |
readSseEvents,
|
|
|
|
| 5 |
startImagesJsonUpstream,
|
| 6 |
startResponsesImageUpstream,
|
| 7 |
startStreamingImageUpstream,
|
|
@@ -111,6 +112,62 @@ describe('POST /api/images backend defaults and security boundaries', { concurre
|
|
| 111 |
}
|
| 112 |
});
|
| 113 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
it('rejects cross-origin GPT2Image URL results before downloading them', async () => {
|
| 115 |
const { POST } = await import('./route');
|
| 116 |
const upstream = await startImagesJsonUpstream(async () => {
|
|
|
|
| 2 |
PNG_BASE64,
|
| 3 |
imageFormRequest,
|
| 4 |
readSseEvents,
|
| 5 |
+
startHttpConnectProxy,
|
| 6 |
startImagesJsonUpstream,
|
| 7 |
startResponsesImageUpstream,
|
| 8 |
startStreamingImageUpstream,
|
|
|
|
| 112 |
}
|
| 113 |
});
|
| 114 |
|
| 115 |
+
it('sends page API requests through the configured global upstream proxy', async () => {
|
| 116 |
+
const { POST } = await import('./route');
|
| 117 |
+
const upstream = await startImagesJsonUpstream(async () => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
| 118 |
+
const proxy = await startHttpConnectProxy();
|
| 119 |
+
process.env.OPENAI_UPSTREAM_PROXY_URL = proxy.url;
|
| 120 |
+
|
| 121 |
+
try {
|
| 122 |
+
const response = await POST(
|
| 123 |
+
imageFormRequest({
|
| 124 |
+
apiBaseUrl: upstream.baseUrl,
|
| 125 |
+
apiKey: 'test-key',
|
| 126 |
+
stream: false,
|
| 127 |
+
streamMode: 'non_stream'
|
| 128 |
+
})
|
| 129 |
+
);
|
| 130 |
+
|
| 131 |
+
assert.equal(response.status, 200);
|
| 132 |
+
assert.ok(proxy.connectTargets.length > 0);
|
| 133 |
+
assert.ok(proxy.connectTargets.every((target) => target === new URL(upstream.baseUrl).host));
|
| 134 |
+
} finally {
|
| 135 |
+
await proxy.close();
|
| 136 |
+
await upstream.close();
|
| 137 |
+
}
|
| 138 |
+
});
|
| 139 |
+
|
| 140 |
+
it('uses a channel proxy instead of the global upstream proxy', async () => {
|
| 141 |
+
const { POST } = await import('./route');
|
| 142 |
+
const upstream = await startImagesJsonUpstream(async () => ({ data: [{ b64_json: PNG_BASE64 }] }));
|
| 143 |
+
const globalProxy = await startHttpConnectProxy();
|
| 144 |
+
const channelProxy = await startHttpConnectProxy();
|
| 145 |
+
process.env.OPENAI_UPSTREAM_PROXY_URL = globalProxy.url;
|
| 146 |
+
process.env.OPENAI_CHANNEL_1_ID = 'proxied-channel';
|
| 147 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
|
| 148 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'channel-key';
|
| 149 |
+
process.env.OPENAI_CHANNEL_1_PROXY_URL = channelProxy.url;
|
| 150 |
+
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
|
| 151 |
+
|
| 152 |
+
try {
|
| 153 |
+
const response = await POST(
|
| 154 |
+
imageFormRequest({
|
| 155 |
+
stream: false,
|
| 156 |
+
streamMode: 'non_stream'
|
| 157 |
+
})
|
| 158 |
+
);
|
| 159 |
+
|
| 160 |
+
assert.equal(response.status, 200);
|
| 161 |
+
assert.ok(channelProxy.connectTargets.length > 0);
|
| 162 |
+
assert.ok(channelProxy.connectTargets.every((target) => target === new URL(upstream.baseUrl).host));
|
| 163 |
+
assert.deepEqual(globalProxy.connectTargets, []);
|
| 164 |
+
} finally {
|
| 165 |
+
await channelProxy.close();
|
| 166 |
+
await globalProxy.close();
|
| 167 |
+
await upstream.close();
|
| 168 |
+
}
|
| 169 |
+
});
|
| 170 |
+
|
| 171 |
it('rejects cross-origin GPT2Image URL results before downloading them', async () => {
|
| 172 |
const { POST } = await import('./route');
|
| 173 |
const upstream = await startImagesJsonUpstream(async () => {
|
src/app/api/images/route-test-helpers.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
import type { NextRequest } from 'next/server';
|
| 2 |
import assert from 'node:assert/strict';
|
| 3 |
import http from 'node:http';
|
|
|
|
| 4 |
|
| 5 |
export { readSseEvents } from '@/lib/sse-test-utils';
|
| 6 |
|
|
@@ -405,6 +406,56 @@ export async function startStreamingResponsesImageUpstream(
|
|
| 405 |
return listen(server);
|
| 406 |
}
|
| 407 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
async function listen(server: http.Server): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
| 409 |
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 410 |
const address = server.address();
|
|
@@ -414,3 +465,22 @@ async function listen(server: http.Server): Promise<{ baseUrl: string; close: ()
|
|
| 414 |
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 415 |
};
|
| 416 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import type { NextRequest } from 'next/server';
|
| 2 |
import assert from 'node:assert/strict';
|
| 3 |
import http from 'node:http';
|
| 4 |
+
import net from 'node:net';
|
| 5 |
|
| 6 |
export { readSseEvents } from '@/lib/sse-test-utils';
|
| 7 |
|
|
|
|
| 406 |
return listen(server);
|
| 407 |
}
|
| 408 |
|
| 409 |
+
export async function startHttpConnectProxy(): Promise<{
|
| 410 |
+
url: string;
|
| 411 |
+
connectTargets: string[];
|
| 412 |
+
close: () => Promise<void>;
|
| 413 |
+
}> {
|
| 414 |
+
const sockets = new Set<net.Socket>();
|
| 415 |
+
const connectTargets: string[] = [];
|
| 416 |
+
const server = http.createServer((_request, response) => {
|
| 417 |
+
response.writeHead(405, { Connection: 'close' });
|
| 418 |
+
response.end();
|
| 419 |
+
});
|
| 420 |
+
server.on('connection', (socket) => {
|
| 421 |
+
sockets.add(socket);
|
| 422 |
+
socket.on('close', () => sockets.delete(socket));
|
| 423 |
+
});
|
| 424 |
+
server.on('connect', (request, clientSocket, head) => {
|
| 425 |
+
const target = parseConnectTarget(request.url);
|
| 426 |
+
if (!target) {
|
| 427 |
+
clientSocket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
|
| 428 |
+
return;
|
| 429 |
+
}
|
| 430 |
+
connectTargets.push(target.host);
|
| 431 |
+
const targetSocket = net.connect({ host: target.hostname, port: Number(target.port) });
|
| 432 |
+
sockets.add(targetSocket);
|
| 433 |
+
targetSocket.on('close', () => sockets.delete(targetSocket));
|
| 434 |
+
targetSocket.once('connect', () => {
|
| 435 |
+
clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
|
| 436 |
+
if (head.length > 0) targetSocket.write(head);
|
| 437 |
+
clientSocket.pipe(targetSocket);
|
| 438 |
+
targetSocket.pipe(clientSocket);
|
| 439 |
+
});
|
| 440 |
+
targetSocket.once('error', () => {
|
| 441 |
+
if (!clientSocket.destroyed) {
|
| 442 |
+
clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
|
| 443 |
+
}
|
| 444 |
+
});
|
| 445 |
+
});
|
| 446 |
+
await new Promise<void>((resolve, reject) => {
|
| 447 |
+
server.once('error', reject);
|
| 448 |
+
server.listen(0, '127.0.0.1', resolve);
|
| 449 |
+
});
|
| 450 |
+
const address = server.address();
|
| 451 |
+
assert.ok(address && typeof address === 'object');
|
| 452 |
+
return {
|
| 453 |
+
url: `http://127.0.0.1:${address.port}`,
|
| 454 |
+
connectTargets,
|
| 455 |
+
close: () => closeServerWithSockets(server, sockets)
|
| 456 |
+
};
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
async function listen(server: http.Server): Promise<{ baseUrl: string; close: () => Promise<void> }> {
|
| 460 |
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
| 461 |
const address = server.address();
|
|
|
|
| 465 |
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
|
| 466 |
};
|
| 467 |
}
|
| 468 |
+
|
| 469 |
+
function parseConnectTarget(rawTarget: string | undefined): URL | undefined {
|
| 470 |
+
if (!rawTarget) return undefined;
|
| 471 |
+
try {
|
| 472 |
+
return new URL(`http://${rawTarget}`);
|
| 473 |
+
} catch {
|
| 474 |
+
return undefined;
|
| 475 |
+
}
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
async function closeServerWithSockets(server: http.Server, sockets: Set<net.Socket>): Promise<void> {
|
| 479 |
+
for (const socket of sockets) socket.destroy();
|
| 480 |
+
await new Promise<void>((resolve, reject) => {
|
| 481 |
+
server.close((error) => {
|
| 482 |
+
if (error) reject(error);
|
| 483 |
+
else resolve();
|
| 484 |
+
});
|
| 485 |
+
});
|
| 486 |
+
}
|
src/app/api/images/route-test-setup.ts
CHANGED
|
@@ -16,6 +16,7 @@ export function registerRouteTestLifecycle() {
|
|
| 16 |
delete process.env.APP_PASSWORD;
|
| 17 |
delete process.env.OPENAI_API_KEY;
|
| 18 |
delete process.env.OPENAI_API_BASE_URL;
|
|
|
|
| 19 |
delete process.env.OPENAI_CHANNEL_1_ID;
|
| 20 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 21 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
|
|
|
| 16 |
delete process.env.APP_PASSWORD;
|
| 17 |
delete process.env.OPENAI_API_KEY;
|
| 18 |
delete process.env.OPENAI_API_BASE_URL;
|
| 19 |
+
delete process.env.OPENAI_UPSTREAM_PROXY_URL;
|
| 20 |
delete process.env.OPENAI_CHANNEL_1_ID;
|
| 21 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 22 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
src/app/api/images/route.ts
CHANGED
|
@@ -427,6 +427,7 @@ export async function POST(request: NextRequest) {
|
|
| 427 |
const {
|
| 428 |
apiKey: effectiveApiKey,
|
| 429 |
baseUrl: effectiveApiBaseUrl,
|
|
|
|
| 430 |
upstreamProfile: effectiveUpstreamProfileId,
|
| 431 |
providerProfile,
|
| 432 |
upstreamHeaders,
|
|
@@ -435,6 +436,7 @@ export async function POST(request: NextRequest) {
|
|
| 435 |
requestApiKey,
|
| 436 |
requestApiBaseUrl,
|
| 437 |
legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
|
|
|
|
| 438 |
selectedCredential: selectedServerCredential
|
| 439 |
});
|
| 440 |
validateApiBaseUrl(effectiveApiBaseUrl || '', { allowedPlainHttpBaseUrls });
|
|
@@ -461,6 +463,7 @@ export async function POST(request: NextRequest) {
|
|
| 461 |
createOpenAIImageClientOptions({
|
| 462 |
apiKey: effectiveApiKey,
|
| 463 |
baseURL: effectiveApiBaseUrl || undefined,
|
|
|
|
| 464 |
defaultHeaders: mergeUpstreamHeadersWithFixed(upstreamHeaders, {})
|
| 465 |
})
|
| 466 |
);
|
|
@@ -577,6 +580,7 @@ export async function POST(request: NextRequest) {
|
|
| 577 |
storageMode: effectiveStorageMode,
|
| 578 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 579 |
apiKey: effectiveApiKey,
|
|
|
|
| 580 |
startedAtMs: upstreamStartedAtMs,
|
| 581 |
upstreamIdempotencyKey,
|
| 582 |
clientRequestId,
|
|
@@ -609,6 +613,7 @@ export async function POST(request: NextRequest) {
|
|
| 609 |
storageMode: effectiveStorageMode,
|
| 610 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 611 |
apiKey: effectiveApiKey,
|
|
|
|
| 612 |
startedAtMs: upstreamStartedAtMs,
|
| 613 |
upstreamIdempotencyKey,
|
| 614 |
clientRequestId,
|
|
@@ -647,6 +652,7 @@ export async function POST(request: NextRequest) {
|
|
| 647 |
normalizeOutputFormat: true,
|
| 648 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 649 |
apiKey: effectiveApiKey,
|
|
|
|
| 650 |
upstreamHeaders,
|
| 651 |
abortSignal: request.signal
|
| 652 |
});
|
|
@@ -657,6 +663,7 @@ export async function POST(request: NextRequest) {
|
|
| 657 |
const actualCost = await resolveRequestActualCostSafely({
|
| 658 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 659 |
apiKey: effectiveApiKey,
|
|
|
|
| 660 |
model,
|
| 661 |
startedAtMs: upstreamStartedAtMs,
|
| 662 |
expectedImageCount: savedImagesData.length,
|
|
|
|
| 427 |
const {
|
| 428 |
apiKey: effectiveApiKey,
|
| 429 |
baseUrl: effectiveApiBaseUrl,
|
| 430 |
+
upstreamProxyUrl: effectiveUpstreamProxyUrl,
|
| 431 |
upstreamProfile: effectiveUpstreamProfileId,
|
| 432 |
providerProfile,
|
| 433 |
upstreamHeaders,
|
|
|
|
| 436 |
requestApiKey,
|
| 437 |
requestApiBaseUrl,
|
| 438 |
legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
|
| 439 |
+
legacyUpstreamProxyUrl: process.env.OPENAI_UPSTREAM_PROXY_URL,
|
| 440 |
selectedCredential: selectedServerCredential
|
| 441 |
});
|
| 442 |
validateApiBaseUrl(effectiveApiBaseUrl || '', { allowedPlainHttpBaseUrls });
|
|
|
|
| 463 |
createOpenAIImageClientOptions({
|
| 464 |
apiKey: effectiveApiKey,
|
| 465 |
baseURL: effectiveApiBaseUrl || undefined,
|
| 466 |
+
upstreamProxyUrl: effectiveUpstreamProxyUrl,
|
| 467 |
defaultHeaders: mergeUpstreamHeadersWithFixed(upstreamHeaders, {})
|
| 468 |
})
|
| 469 |
);
|
|
|
|
| 580 |
storageMode: effectiveStorageMode,
|
| 581 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 582 |
apiKey: effectiveApiKey,
|
| 583 |
+
upstreamProxyUrl: effectiveUpstreamProxyUrl,
|
| 584 |
startedAtMs: upstreamStartedAtMs,
|
| 585 |
upstreamIdempotencyKey,
|
| 586 |
clientRequestId,
|
|
|
|
| 613 |
storageMode: effectiveStorageMode,
|
| 614 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 615 |
apiKey: effectiveApiKey,
|
| 616 |
+
upstreamProxyUrl: effectiveUpstreamProxyUrl,
|
| 617 |
startedAtMs: upstreamStartedAtMs,
|
| 618 |
upstreamIdempotencyKey,
|
| 619 |
clientRequestId,
|
|
|
|
| 652 |
normalizeOutputFormat: true,
|
| 653 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 654 |
apiKey: effectiveApiKey,
|
| 655 |
+
upstreamProxyUrl: effectiveUpstreamProxyUrl,
|
| 656 |
upstreamHeaders,
|
| 657 |
abortSignal: request.signal
|
| 658 |
});
|
|
|
|
| 663 |
const actualCost = await resolveRequestActualCostSafely({
|
| 664 |
apiBaseUrl: effectiveApiBaseUrl,
|
| 665 |
apiKey: effectiveApiKey,
|
| 666 |
+
upstreamProxyUrl: effectiveUpstreamProxyUrl,
|
| 667 |
model,
|
| 668 |
startedAtMs: upstreamStartedAtMs,
|
| 669 |
expectedImageCount: savedImagesData.length,
|
src/app/api/runtime-capabilities/route.test.ts
CHANGED
|
@@ -33,6 +33,7 @@ beforeEach(async () => {
|
|
| 33 |
delete process.env.IMAGE_UPSTREAM_TIMEOUT_MS;
|
| 34 |
delete process.env.IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS;
|
| 35 |
delete process.env.IMAGE_UPSTREAM_MAX_RETRIES;
|
|
|
|
| 36 |
delete process.env.OPENAI_API_KEY;
|
| 37 |
delete process.env.OPENAI_API_BASE_URL;
|
| 38 |
delete process.env.OPENAI_ROUTING_STRATEGY;
|
|
@@ -43,6 +44,7 @@ beforeEach(async () => {
|
|
| 43 |
delete process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST;
|
| 44 |
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES;
|
| 45 |
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY;
|
|
|
|
| 46 |
delete process.env.OPENAI_CHANNEL_2_ID;
|
| 47 |
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
|
| 48 |
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
|
|
@@ -50,6 +52,7 @@ beforeEach(async () => {
|
|
| 50 |
delete process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST;
|
| 51 |
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODES;
|
| 52 |
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY;
|
|
|
|
| 53 |
delete process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED;
|
| 54 |
delete process.env.OPENAI_CHANNEL_QUEUE_ENABLED;
|
| 55 |
delete process.env.OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS;
|
|
@@ -215,6 +218,7 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 215 |
upstream_timeout_ms?: number;
|
| 216 |
stream_data_interval_timeout_ms?: number;
|
| 217 |
upstream_max_retries?: number;
|
|
|
|
| 218 |
}
|
| 219 |
>;
|
| 220 |
|
|
@@ -224,7 +228,8 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 224 |
assert.deepEqual(body.imageTransport, {
|
| 225 |
upstream_timeout_ms: 1_200_000,
|
| 226 |
stream_data_interval_timeout_ms: 600_000,
|
| 227 |
-
upstream_max_retries: 1
|
|
|
|
| 228 |
});
|
| 229 |
});
|
| 230 |
|
|
@@ -612,6 +617,10 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 612 |
configuredChannelCount: number;
|
| 613 |
healthyChannelCount: number;
|
| 614 |
}>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 615 |
requestModesByChannel: Array<{
|
| 616 |
channelId: string;
|
| 617 |
requestModes: string[];
|
|
@@ -689,6 +698,12 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 689 |
healthyChannelCount: 0
|
| 690 |
}
|
| 691 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
requestModesByChannel: [
|
| 693 |
{
|
| 694 |
channelId: 'images',
|
|
|
|
| 33 |
delete process.env.IMAGE_UPSTREAM_TIMEOUT_MS;
|
| 34 |
delete process.env.IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS;
|
| 35 |
delete process.env.IMAGE_UPSTREAM_MAX_RETRIES;
|
| 36 |
+
delete process.env.OPENAI_UPSTREAM_PROXY_URL;
|
| 37 |
delete process.env.OPENAI_API_KEY;
|
| 38 |
delete process.env.OPENAI_API_BASE_URL;
|
| 39 |
delete process.env.OPENAI_ROUTING_STRATEGY;
|
|
|
|
| 44 |
delete process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST;
|
| 45 |
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES;
|
| 46 |
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY;
|
| 47 |
+
delete process.env.OPENAI_CHANNEL_1_PROXY_URL;
|
| 48 |
delete process.env.OPENAI_CHANNEL_2_ID;
|
| 49 |
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
|
| 50 |
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
|
|
|
|
| 52 |
delete process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST;
|
| 53 |
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODES;
|
| 54 |
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY;
|
| 55 |
+
delete process.env.OPENAI_CHANNEL_2_PROXY_URL;
|
| 56 |
delete process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED;
|
| 57 |
delete process.env.OPENAI_CHANNEL_QUEUE_ENABLED;
|
| 58 |
delete process.env.OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS;
|
|
|
|
| 218 |
upstream_timeout_ms?: number;
|
| 219 |
stream_data_interval_timeout_ms?: number;
|
| 220 |
upstream_max_retries?: number;
|
| 221 |
+
upstream_proxy?: { configured: boolean; protocol?: string };
|
| 222 |
}
|
| 223 |
>;
|
| 224 |
|
|
|
|
| 228 |
assert.deepEqual(body.imageTransport, {
|
| 229 |
upstream_timeout_ms: 1_200_000,
|
| 230 |
stream_data_interval_timeout_ms: 600_000,
|
| 231 |
+
upstream_max_retries: 1,
|
| 232 |
+
upstream_proxy: { configured: false }
|
| 233 |
});
|
| 234 |
});
|
| 235 |
|
|
|
|
| 617 |
configuredChannelCount: number;
|
| 618 |
healthyChannelCount: number;
|
| 619 |
}>;
|
| 620 |
+
upstreamProxyByChannel: Array<{
|
| 621 |
+
channelId: string;
|
| 622 |
+
upstreamProxy: { configured: boolean; protocol?: string };
|
| 623 |
+
}>;
|
| 624 |
requestModesByChannel: Array<{
|
| 625 |
channelId: string;
|
| 626 |
requestModes: string[];
|
|
|
|
| 698 |
healthyChannelCount: 0
|
| 699 |
}
|
| 700 |
],
|
| 701 |
+
upstreamProxyByChannel: [
|
| 702 |
+
{
|
| 703 |
+
channelId: 'images',
|
| 704 |
+
upstreamProxy: { configured: false }
|
| 705 |
+
}
|
| 706 |
+
],
|
| 707 |
requestModesByChannel: [
|
| 708 |
{
|
| 709 |
channelId: 'images',
|
src/app/api/runtime-capabilities/route.ts
CHANGED
|
@@ -98,6 +98,10 @@ export async function GET() {
|
|
| 98 |
CHANNEL_REQUEST_MODE_ADMIN_CONTROL.defaultPriority,
|
| 99 |
requestModeControls: CHANNEL_REQUEST_MODE_ADMIN_CONTROL,
|
| 100 |
requestModeHealth: requestModeHealthSummary?.modes ?? [],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
requestModesByChannel: summary.channels.map((channel) => ({
|
| 102 |
channelId: channel.id,
|
| 103 |
requestModes: channel.requestModes,
|
|
|
|
| 98 |
CHANNEL_REQUEST_MODE_ADMIN_CONTROL.defaultPriority,
|
| 99 |
requestModeControls: CHANNEL_REQUEST_MODE_ADMIN_CONTROL,
|
| 100 |
requestModeHealth: requestModeHealthSummary?.modes ?? [],
|
| 101 |
+
upstreamProxyByChannel: summary.channels.map((channel) => ({
|
| 102 |
+
channelId: channel.id,
|
| 103 |
+
upstreamProxy: channel.upstreamProxy
|
| 104 |
+
})),
|
| 105 |
requestModesByChannel: summary.channels.map((channel) => ({
|
| 106 |
channelId: channel.id,
|
| 107 |
requestModes: channel.requestModes,
|
src/lib/agent-api-contracts.test.ts
CHANGED
|
@@ -394,7 +394,8 @@ describe('buildAgentCapabilities', () => {
|
|
| 394 |
assert.deepEqual(capabilities.image_transport, {
|
| 395 |
upstream_timeout_ms: 900_000,
|
| 396 |
stream_data_interval_timeout_ms: 900_000,
|
| 397 |
-
upstream_max_retries: 0
|
|
|
|
| 398 |
});
|
| 399 |
assert.equal(capabilities.defaults.image_backend, 'images-api');
|
| 400 |
assert.equal(capabilities.defaults.stream_mode, 'auto');
|
|
@@ -713,6 +714,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 713 |
assert.deepEqual(capabilities.upstream_request_headers.channels, [
|
| 714 |
{
|
| 715 |
id: 'images',
|
|
|
|
| 716 |
request_modes: ['images-non-stream', 'images-sse'],
|
| 717 |
request_mode_priority: ['images-non-stream', 'images-sse'],
|
| 718 |
request_headers: {
|
|
@@ -725,6 +727,36 @@ describe('buildAgentCapabilities', () => {
|
|
| 725 |
]);
|
| 726 |
});
|
| 727 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
it('reports Matsca server-channel upload and image-count limits in Agent capabilities', () => {
|
| 729 |
const capabilities = buildAgentCapabilities({
|
| 730 |
OPENAI_CHANNEL_1_ID: 'matsca',
|
|
@@ -989,6 +1021,7 @@ describe('buildAgentCapabilities', () => {
|
|
| 989 |
assert.ok('AgentImageResponseTiming' in document.components.schemas);
|
| 990 |
assert.ok('AgentImageResponseExecution' in document.components.schemas);
|
| 991 |
assert.ok('ChannelRequestModeDecision' in document.components.schemas);
|
|
|
|
| 992 |
assert.deepEqual(document.components.schemas.AgentImageResponseExecution.properties.channel_request_mode.enum, [
|
| 993 |
'images-non-stream',
|
| 994 |
'images-sse',
|
|
@@ -1108,6 +1141,11 @@ describe('buildAgentCapabilities', () => {
|
|
| 1108 |
document.components.schemas.ImageTransportCapabilities.properties.upstream_timeout_ms.const,
|
| 1109 |
900000
|
| 1110 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1111 |
assert.equal(
|
| 1112 |
capabilityProperties.upstream_request_headers.properties.default.$ref,
|
| 1113 |
'#/components/schemas/UpstreamRequestHeaderSummary'
|
|
|
|
| 394 |
assert.deepEqual(capabilities.image_transport, {
|
| 395 |
upstream_timeout_ms: 900_000,
|
| 396 |
stream_data_interval_timeout_ms: 900_000,
|
| 397 |
+
upstream_max_retries: 0,
|
| 398 |
+
upstream_proxy: { configured: false }
|
| 399 |
});
|
| 400 |
assert.equal(capabilities.defaults.image_backend, 'images-api');
|
| 401 |
assert.equal(capabilities.defaults.stream_mode, 'auto');
|
|
|
|
| 714 |
assert.deepEqual(capabilities.upstream_request_headers.channels, [
|
| 715 |
{
|
| 716 |
id: 'images',
|
| 717 |
+
upstream_proxy: { configured: false },
|
| 718 |
request_modes: ['images-non-stream', 'images-sse'],
|
| 719 |
request_mode_priority: ['images-non-stream', 'images-sse'],
|
| 720 |
request_headers: {
|
|
|
|
| 727 |
]);
|
| 728 |
});
|
| 729 |
|
| 730 |
+
it('reports global and per-channel upstream proxy summaries without exposing endpoints', () => {
|
| 731 |
+
const capabilities = buildAgentCapabilities({
|
| 732 |
+
OPENAI_UPSTREAM_PROXY_URL: 'https://global-proxy.internal.example:9443',
|
| 733 |
+
OPENAI_CHANNEL_1_ID: 'primary',
|
| 734 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1',
|
| 735 |
+
OPENAI_CHANNEL_1_API_KEYS: 'configured',
|
| 736 |
+
OPENAI_CHANNEL_2_ID: 'backup',
|
| 737 |
+
OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1',
|
| 738 |
+
OPENAI_CHANNEL_2_API_KEYS: 'configured',
|
| 739 |
+
OPENAI_CHANNEL_2_PROXY_URL: 'http://channel-proxy.internal.example:8080'
|
| 740 |
+
});
|
| 741 |
+
|
| 742 |
+
assert.deepEqual(capabilities.image_transport.upstream_proxy, { configured: true, protocol: 'https' });
|
| 743 |
+
assert.deepEqual(
|
| 744 |
+
capabilities.upstream_request_headers.channels.map((channel) => ({
|
| 745 |
+
id: channel.id,
|
| 746 |
+
upstream_proxy: channel.upstream_proxy
|
| 747 |
+
})),
|
| 748 |
+
[
|
| 749 |
+
{ id: 'primary', upstream_proxy: { configured: true, protocol: 'https' } },
|
| 750 |
+
{ id: 'backup', upstream_proxy: { configured: true, protocol: 'http' } }
|
| 751 |
+
]
|
| 752 |
+
);
|
| 753 |
+
const serialized = JSON.stringify(capabilities);
|
| 754 |
+
assert.equal(serialized.includes('global-proxy.internal.example'), false);
|
| 755 |
+
assert.equal(serialized.includes('channel-proxy.internal.example'), false);
|
| 756 |
+
assert.equal(serialized.includes('9443'), false);
|
| 757 |
+
assert.equal(serialized.includes('8080'), false);
|
| 758 |
+
});
|
| 759 |
+
|
| 760 |
it('reports Matsca server-channel upload and image-count limits in Agent capabilities', () => {
|
| 761 |
const capabilities = buildAgentCapabilities({
|
| 762 |
OPENAI_CHANNEL_1_ID: 'matsca',
|
|
|
|
| 1021 |
assert.ok('AgentImageResponseTiming' in document.components.schemas);
|
| 1022 |
assert.ok('AgentImageResponseExecution' in document.components.schemas);
|
| 1023 |
assert.ok('ChannelRequestModeDecision' in document.components.schemas);
|
| 1024 |
+
assert.ok('UpstreamProxySummary' in document.components.schemas);
|
| 1025 |
assert.deepEqual(document.components.schemas.AgentImageResponseExecution.properties.channel_request_mode.enum, [
|
| 1026 |
'images-non-stream',
|
| 1027 |
'images-sse',
|
|
|
|
| 1141 |
document.components.schemas.ImageTransportCapabilities.properties.upstream_timeout_ms.const,
|
| 1142 |
900000
|
| 1143 |
);
|
| 1144 |
+
assert.equal(
|
| 1145 |
+
document.components.schemas.ImageTransportCapabilities.properties.upstream_proxy.$ref,
|
| 1146 |
+
'#/components/schemas/UpstreamProxySummary'
|
| 1147 |
+
);
|
| 1148 |
+
assert.deepEqual(document.components.schemas.UpstreamProxySummary.required, ['configured']);
|
| 1149 |
assert.equal(
|
| 1150 |
capabilityProperties.upstream_request_headers.properties.default.$ref,
|
| 1151 |
'#/components/schemas/UpstreamRequestHeaderSummary'
|
src/lib/agent-api-contracts.ts
CHANGED
|
@@ -36,7 +36,7 @@ import {
|
|
| 36 |
type ImageStreamMode,
|
| 37 |
type ImageStreamingStrategy
|
| 38 |
} from './image-upstream-strategy';
|
| 39 |
-
import { summarizeOpenAIImageTransport } from './openai-image-transport';
|
| 40 |
import { CHINESE_POSITIVE_INTEGER_MESSAGES, readPositiveIntegerFromEnv } from './positive-integer-config.mjs';
|
| 41 |
import { readBooleanEnv } from './server-runtime';
|
| 42 |
import {
|
|
@@ -237,12 +237,14 @@ export type AgentCapabilities = {
|
|
| 237 |
upstream_timeout_ms: number;
|
| 238 |
stream_data_interval_timeout_ms: number;
|
| 239 |
upstream_max_retries: number;
|
|
|
|
| 240 |
};
|
| 241 |
upstream_profile: ImageUpstreamProfileSummary;
|
| 242 |
upstream_request_headers: {
|
| 243 |
default: UpstreamRequestHeaderSummary;
|
| 244 |
channels: Array<{
|
| 245 |
id: string;
|
|
|
|
| 246 |
request_modes: readonly ChannelRequestMode[];
|
| 247 |
request_mode_priority: readonly ChannelRequestMode[];
|
| 248 |
request_headers: UpstreamRequestHeaderSummary;
|
|
@@ -1331,6 +1333,7 @@ function buildAgentUpstreamRequestHeadersCapabilities(
|
|
| 1331 |
default: summarizeUpstreamRequestHeaders(undefined, env),
|
| 1332 |
channels: channelSummary.channels.map((channel) => ({
|
| 1333 |
id: channel.id,
|
|
|
|
| 1334 |
request_modes: channel.requestModes,
|
| 1335 |
request_mode_priority: channel.requestModePriority,
|
| 1336 |
request_headers: channel.requestHeaders
|
|
|
|
| 36 |
type ImageStreamMode,
|
| 37 |
type ImageStreamingStrategy
|
| 38 |
} from './image-upstream-strategy';
|
| 39 |
+
import { summarizeOpenAIImageTransport, type UpstreamProxySummary } from './openai-image-transport';
|
| 40 |
import { CHINESE_POSITIVE_INTEGER_MESSAGES, readPositiveIntegerFromEnv } from './positive-integer-config.mjs';
|
| 41 |
import { readBooleanEnv } from './server-runtime';
|
| 42 |
import {
|
|
|
|
| 237 |
upstream_timeout_ms: number;
|
| 238 |
stream_data_interval_timeout_ms: number;
|
| 239 |
upstream_max_retries: number;
|
| 240 |
+
upstream_proxy: UpstreamProxySummary;
|
| 241 |
};
|
| 242 |
upstream_profile: ImageUpstreamProfileSummary;
|
| 243 |
upstream_request_headers: {
|
| 244 |
default: UpstreamRequestHeaderSummary;
|
| 245 |
channels: Array<{
|
| 246 |
id: string;
|
| 247 |
+
upstream_proxy: UpstreamProxySummary;
|
| 248 |
request_modes: readonly ChannelRequestMode[];
|
| 249 |
request_mode_priority: readonly ChannelRequestMode[];
|
| 250 |
request_headers: UpstreamRequestHeaderSummary;
|
|
|
|
| 1333 |
default: summarizeUpstreamRequestHeaders(undefined, env),
|
| 1334 |
channels: channelSummary.channels.map((channel) => ({
|
| 1335 |
id: channel.id,
|
| 1336 |
+
upstream_proxy: channel.upstreamProxy,
|
| 1337 |
request_modes: channel.requestModes,
|
| 1338 |
request_mode_priority: channel.requestModePriority,
|
| 1339 |
request_headers: channel.requestHeaders
|
src/lib/agent-image-service.ts
CHANGED
|
@@ -118,6 +118,7 @@ type CredentialContext = {
|
|
| 118 |
channelRequestModeDecision: ChannelRequestModeDecision;
|
| 119 |
baseUrl?: string;
|
| 120 |
apiKey: string;
|
|
|
|
| 121 |
upstreamProfile: ImageUpstreamProfile;
|
| 122 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 123 |
};
|
|
@@ -469,6 +470,7 @@ export async function executeAgentGenerate(options: {
|
|
| 469 |
cached: options.cached,
|
| 470 |
apiBaseUrl: credentialContext.baseUrl,
|
| 471 |
apiKey: credentialContext.apiKey,
|
|
|
|
| 472 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 473 |
execution: {
|
| 474 |
startedAtMs,
|
|
@@ -559,6 +561,7 @@ async function executeAgentGenerateUpstream(
|
|
| 559 |
const stream = await createImagesApiGenerateStream({
|
| 560 |
apiBaseUrl: credentialContext.baseUrl,
|
| 561 |
apiKey: credentialContext.apiKey,
|
|
|
|
| 562 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 563 |
idempotencyKey,
|
| 564 |
abortSignal,
|
|
@@ -571,6 +574,7 @@ async function executeAgentGenerateUpstream(
|
|
| 571 |
return await collectOpenAiImagesFromStream(stream, {
|
| 572 |
apiBaseUrl: credentialContext.baseUrl,
|
| 573 |
apiKey: credentialContext.apiKey,
|
|
|
|
| 574 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 575 |
abortSignal,
|
| 576 |
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200)
|
|
@@ -667,6 +671,7 @@ async function executeAgentResponsesGenerate(
|
|
| 667 |
{
|
| 668 |
apiBaseUrl: credentialContext.baseUrl,
|
| 669 |
apiKey: credentialContext.apiKey,
|
|
|
|
| 670 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 671 |
abortSignal,
|
| 672 |
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200)
|
|
@@ -837,6 +842,7 @@ export async function executeAgentEdit(options: {
|
|
| 837 |
cached: options.cached,
|
| 838 |
apiBaseUrl: activeCredentialContext.baseUrl,
|
| 839 |
apiKey: activeCredentialContext.apiKey,
|
|
|
|
| 840 |
upstreamHeaders: activeCredentialContext.upstreamHeaders,
|
| 841 |
execution: {
|
| 842 |
startedAtMs,
|
|
@@ -900,6 +906,7 @@ async function executeAgentEditStream(input: {
|
|
| 900 |
return await collectOpenAiImagesFromStream(stream, {
|
| 901 |
apiBaseUrl: input.credentialContext.baseUrl,
|
| 902 |
apiKey: input.credentialContext.apiKey,
|
|
|
|
| 903 |
upstreamHeaders: input.credentialContext.upstreamHeaders,
|
| 904 |
abortSignal: input.abortSignal,
|
| 905 |
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(input.streamOptions, reason, 200)
|
|
@@ -1072,12 +1079,14 @@ function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelReque
|
|
| 1072 |
const {
|
| 1073 |
apiKey,
|
| 1074 |
baseUrl,
|
|
|
|
| 1075 |
providerProfile,
|
| 1076 |
selectedCredential: effectiveSelectedCredential
|
| 1077 |
} = resolveEffectiveCredential({
|
| 1078 |
requestApiKey: '',
|
| 1079 |
requestApiBaseUrl: '',
|
| 1080 |
legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
|
|
|
|
| 1081 |
selectedCredential
|
| 1082 |
});
|
| 1083 |
validateApiBaseUrl(baseUrl || '', {
|
|
@@ -1105,6 +1114,7 @@ function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelReque
|
|
| 1105 |
createOpenAIImageClientOptions({
|
| 1106 |
apiKey,
|
| 1107 |
baseURL: baseUrl || undefined,
|
|
|
|
| 1108 |
defaultHeaders: mergeUpstreamHeadersWithFixed(effectiveSelectedCredential?.upstreamHeaders, {})
|
| 1109 |
})
|
| 1110 |
),
|
|
@@ -1114,6 +1124,7 @@ function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelReque
|
|
| 1114 |
channelRequestModeDecision,
|
| 1115 |
baseUrl,
|
| 1116 |
apiKey,
|
|
|
|
| 1117 |
upstreamProfile:
|
| 1118 |
providerProfile ||
|
| 1119 |
readImageUpstreamProfile({
|
|
@@ -1230,6 +1241,7 @@ async function persistOpenAiImages(options: {
|
|
| 1230 |
cached: boolean;
|
| 1231 |
apiBaseUrl?: string;
|
| 1232 |
apiKey?: string;
|
|
|
|
| 1233 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 1234 |
execution: AgentExecutionMetadata;
|
| 1235 |
abortSignal?: AbortSignal;
|
|
@@ -1244,6 +1256,7 @@ async function persistOpenAiImages(options: {
|
|
| 1244 |
normalizeOutputFormat: options.normalizeOutputFormat,
|
| 1245 |
apiBaseUrl: options.apiBaseUrl,
|
| 1246 |
apiKey: options.apiKey,
|
|
|
|
| 1247 |
upstreamHeaders: options.upstreamHeaders,
|
| 1248 |
abortSignal: options.abortSignal
|
| 1249 |
});
|
|
|
|
| 118 |
channelRequestModeDecision: ChannelRequestModeDecision;
|
| 119 |
baseUrl?: string;
|
| 120 |
apiKey: string;
|
| 121 |
+
upstreamProxyUrl?: string;
|
| 122 |
upstreamProfile: ImageUpstreamProfile;
|
| 123 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 124 |
};
|
|
|
|
| 470 |
cached: options.cached,
|
| 471 |
apiBaseUrl: credentialContext.baseUrl,
|
| 472 |
apiKey: credentialContext.apiKey,
|
| 473 |
+
upstreamProxyUrl: credentialContext.upstreamProxyUrl,
|
| 474 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 475 |
execution: {
|
| 476 |
startedAtMs,
|
|
|
|
| 561 |
const stream = await createImagesApiGenerateStream({
|
| 562 |
apiBaseUrl: credentialContext.baseUrl,
|
| 563 |
apiKey: credentialContext.apiKey,
|
| 564 |
+
upstreamProxyUrl: credentialContext.upstreamProxyUrl,
|
| 565 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 566 |
idempotencyKey,
|
| 567 |
abortSignal,
|
|
|
|
| 574 |
return await collectOpenAiImagesFromStream(stream, {
|
| 575 |
apiBaseUrl: credentialContext.baseUrl,
|
| 576 |
apiKey: credentialContext.apiKey,
|
| 577 |
+
upstreamProxyUrl: credentialContext.upstreamProxyUrl,
|
| 578 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 579 |
abortSignal,
|
| 580 |
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200)
|
|
|
|
| 671 |
{
|
| 672 |
apiBaseUrl: credentialContext.baseUrl,
|
| 673 |
apiKey: credentialContext.apiKey,
|
| 674 |
+
upstreamProxyUrl: credentialContext.upstreamProxyUrl,
|
| 675 |
upstreamHeaders: credentialContext.upstreamHeaders,
|
| 676 |
abortSignal,
|
| 677 |
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200)
|
|
|
|
| 842 |
cached: options.cached,
|
| 843 |
apiBaseUrl: activeCredentialContext.baseUrl,
|
| 844 |
apiKey: activeCredentialContext.apiKey,
|
| 845 |
+
upstreamProxyUrl: activeCredentialContext.upstreamProxyUrl,
|
| 846 |
upstreamHeaders: activeCredentialContext.upstreamHeaders,
|
| 847 |
execution: {
|
| 848 |
startedAtMs,
|
|
|
|
| 906 |
return await collectOpenAiImagesFromStream(stream, {
|
| 907 |
apiBaseUrl: input.credentialContext.baseUrl,
|
| 908 |
apiKey: input.credentialContext.apiKey,
|
| 909 |
+
upstreamProxyUrl: input.credentialContext.upstreamProxyUrl,
|
| 910 |
upstreamHeaders: input.credentialContext.upstreamHeaders,
|
| 911 |
abortSignal: input.abortSignal,
|
| 912 |
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(input.streamOptions, reason, 200)
|
|
|
|
| 1079 |
const {
|
| 1080 |
apiKey,
|
| 1081 |
baseUrl,
|
| 1082 |
+
upstreamProxyUrl,
|
| 1083 |
providerProfile,
|
| 1084 |
selectedCredential: effectiveSelectedCredential
|
| 1085 |
} = resolveEffectiveCredential({
|
| 1086 |
requestApiKey: '',
|
| 1087 |
requestApiBaseUrl: '',
|
| 1088 |
legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
|
| 1089 |
+
legacyUpstreamProxyUrl: process.env.OPENAI_UPSTREAM_PROXY_URL,
|
| 1090 |
selectedCredential
|
| 1091 |
});
|
| 1092 |
validateApiBaseUrl(baseUrl || '', {
|
|
|
|
| 1114 |
createOpenAIImageClientOptions({
|
| 1115 |
apiKey,
|
| 1116 |
baseURL: baseUrl || undefined,
|
| 1117 |
+
upstreamProxyUrl,
|
| 1118 |
defaultHeaders: mergeUpstreamHeadersWithFixed(effectiveSelectedCredential?.upstreamHeaders, {})
|
| 1119 |
})
|
| 1120 |
),
|
|
|
|
| 1124 |
channelRequestModeDecision,
|
| 1125 |
baseUrl,
|
| 1126 |
apiKey,
|
| 1127 |
+
upstreamProxyUrl,
|
| 1128 |
upstreamProfile:
|
| 1129 |
providerProfile ||
|
| 1130 |
readImageUpstreamProfile({
|
|
|
|
| 1241 |
cached: boolean;
|
| 1242 |
apiBaseUrl?: string;
|
| 1243 |
apiKey?: string;
|
| 1244 |
+
upstreamProxyUrl?: string;
|
| 1245 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 1246 |
execution: AgentExecutionMetadata;
|
| 1247 |
abortSignal?: AbortSignal;
|
|
|
|
| 1256 |
normalizeOutputFormat: options.normalizeOutputFormat,
|
| 1257 |
apiBaseUrl: options.apiBaseUrl,
|
| 1258 |
apiKey: options.apiKey,
|
| 1259 |
+
upstreamProxyUrl: options.upstreamProxyUrl,
|
| 1260 |
upstreamHeaders: options.upstreamHeaders,
|
| 1261 |
abortSignal: options.abortSignal
|
| 1262 |
});
|
src/lib/agent-openapi.ts
CHANGED
|
@@ -504,9 +504,16 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 504 |
type: 'array',
|
| 505 |
items: {
|
| 506 |
type: 'object',
|
| 507 |
-
required: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 508 |
properties: {
|
| 509 |
id: { type: 'string' },
|
|
|
|
| 510 |
request_modes: {
|
| 511 |
type: 'array',
|
| 512 |
items: { type: 'string', enum: CHANNEL_REQUEST_MODES }
|
|
@@ -1079,10 +1086,12 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1079 |
'unhealthy_credential_count',
|
| 1080 |
'state',
|
| 1081 |
'probe_required',
|
|
|
|
| 1082 |
'credentials'
|
| 1083 |
],
|
| 1084 |
properties: {
|
| 1085 |
channel_id: { type: 'string' },
|
|
|
|
| 1086 |
credential_count: { type: 'integer', minimum: 0 },
|
| 1087 |
healthy_credential_count: { type: 'integer', minimum: 0 },
|
| 1088 |
unhealthy_credential_count: { type: 'integer', minimum: 0 },
|
|
@@ -2197,7 +2206,12 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 2197 |
},
|
| 2198 |
ImageTransportCapabilities: {
|
| 2199 |
type: 'object',
|
| 2200 |
-
required: [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2201 |
properties: {
|
| 2202 |
upstream_timeout_ms: {
|
| 2203 |
type: 'integer',
|
|
@@ -2213,9 +2227,21 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 2213 |
type: 'integer',
|
| 2214 |
minimum: 0,
|
| 2215 |
const: capabilities.image_transport.upstream_max_retries
|
|
|
|
|
|
|
|
|
|
| 2216 |
}
|
| 2217 |
},
|
| 2218 |
additionalProperties: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2219 |
}
|
| 2220 |
}
|
| 2221 |
}
|
|
|
|
| 504 |
type: 'array',
|
| 505 |
items: {
|
| 506 |
type: 'object',
|
| 507 |
+
required: [
|
| 508 |
+
'id',
|
| 509 |
+
'upstream_proxy',
|
| 510 |
+
'request_modes',
|
| 511 |
+
'request_mode_priority',
|
| 512 |
+
'request_headers'
|
| 513 |
+
],
|
| 514 |
properties: {
|
| 515 |
id: { type: 'string' },
|
| 516 |
+
upstream_proxy: { $ref: '#/components/schemas/UpstreamProxySummary' },
|
| 517 |
request_modes: {
|
| 518 |
type: 'array',
|
| 519 |
items: { type: 'string', enum: CHANNEL_REQUEST_MODES }
|
|
|
|
| 1086 |
'unhealthy_credential_count',
|
| 1087 |
'state',
|
| 1088 |
'probe_required',
|
| 1089 |
+
'upstream_proxy',
|
| 1090 |
'credentials'
|
| 1091 |
],
|
| 1092 |
properties: {
|
| 1093 |
channel_id: { type: 'string' },
|
| 1094 |
+
upstream_proxy: { $ref: '#/components/schemas/UpstreamProxySummary' },
|
| 1095 |
credential_count: { type: 'integer', minimum: 0 },
|
| 1096 |
healthy_credential_count: { type: 'integer', minimum: 0 },
|
| 1097 |
unhealthy_credential_count: { type: 'integer', minimum: 0 },
|
|
|
|
| 2206 |
},
|
| 2207 |
ImageTransportCapabilities: {
|
| 2208 |
type: 'object',
|
| 2209 |
+
required: [
|
| 2210 |
+
'upstream_timeout_ms',
|
| 2211 |
+
'stream_data_interval_timeout_ms',
|
| 2212 |
+
'upstream_max_retries',
|
| 2213 |
+
'upstream_proxy'
|
| 2214 |
+
],
|
| 2215 |
properties: {
|
| 2216 |
upstream_timeout_ms: {
|
| 2217 |
type: 'integer',
|
|
|
|
| 2227 |
type: 'integer',
|
| 2228 |
minimum: 0,
|
| 2229 |
const: capabilities.image_transport.upstream_max_retries
|
| 2230 |
+
},
|
| 2231 |
+
upstream_proxy: {
|
| 2232 |
+
$ref: '#/components/schemas/UpstreamProxySummary'
|
| 2233 |
}
|
| 2234 |
},
|
| 2235 |
additionalProperties: false
|
| 2236 |
+
},
|
| 2237 |
+
UpstreamProxySummary: {
|
| 2238 |
+
type: 'object',
|
| 2239 |
+
required: ['configured'],
|
| 2240 |
+
properties: {
|
| 2241 |
+
configured: { type: 'boolean' },
|
| 2242 |
+
protocol: { type: 'string', enum: ['http', 'https'] }
|
| 2243 |
+
},
|
| 2244 |
+
additionalProperties: false
|
| 2245 |
}
|
| 2246 |
}
|
| 2247 |
}
|
src/lib/channel-health-prober.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
| 6 |
type ChannelRouter
|
| 7 |
} from './channel-router';
|
| 8 |
import { mergeUpstreamHeadersWithFixed } from './image-upstream-profile';
|
|
|
|
| 9 |
|
| 10 |
type ProbeFetch = (input: URL, init: RequestInit) => Promise<Response>;
|
| 11 |
type ProbeResult = {
|
|
@@ -69,14 +70,18 @@ export async function probeChannelModelsEndpoint(input: {
|
|
| 69 |
const abortController = new AbortController();
|
| 70 |
const timeout = setTimeout(() => abortController.abort(), input.timeoutMs);
|
| 71 |
try {
|
| 72 |
-
const
|
| 73 |
method: 'GET',
|
| 74 |
headers: mergeUpstreamHeadersWithFixed(input.credential.upstreamHeaders, {
|
| 75 |
Authorization: `Bearer ${input.credential.apiKey}`,
|
| 76 |
Accept: 'application/json'
|
| 77 |
}),
|
| 78 |
signal: abortController.signal
|
| 79 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
if (!response.ok) {
|
| 81 |
return {
|
| 82 |
ok: false,
|
|
|
|
| 6 |
type ChannelRouter
|
| 7 |
} from './channel-router';
|
| 8 |
import { mergeUpstreamHeadersWithFixed } from './image-upstream-profile';
|
| 9 |
+
import { fetchOpenAIUpstream } from './openai-image-transport';
|
| 10 |
|
| 11 |
type ProbeFetch = (input: URL, init: RequestInit) => Promise<Response>;
|
| 12 |
type ProbeResult = {
|
|
|
|
| 70 |
const abortController = new AbortController();
|
| 71 |
const timeout = setTimeout(() => abortController.abort(), input.timeoutMs);
|
| 72 |
try {
|
| 73 |
+
const requestInit = {
|
| 74 |
method: 'GET',
|
| 75 |
headers: mergeUpstreamHeadersWithFixed(input.credential.upstreamHeaders, {
|
| 76 |
Authorization: `Bearer ${input.credential.apiKey}`,
|
| 77 |
Accept: 'application/json'
|
| 78 |
}),
|
| 79 |
signal: abortController.signal
|
| 80 |
+
} satisfies RequestInit;
|
| 81 |
+
const targetUrl = buildModelsUrl(input.credential.baseUrl);
|
| 82 |
+
const response = input.fetchImpl
|
| 83 |
+
? await input.fetchImpl(targetUrl, requestInit)
|
| 84 |
+
: await fetchOpenAIUpstream(targetUrl, requestInit, input.credential.upstreamProxyUrl);
|
| 85 |
if (!response.ok) {
|
| 86 |
return {
|
| 87 |
ok: false,
|
src/lib/channel-health-snapshot.test.ts
CHANGED
|
@@ -35,6 +35,7 @@ describe('channel health snapshot', () => {
|
|
| 35 |
channels: [
|
| 36 |
{
|
| 37 |
channelId: 'primary',
|
|
|
|
| 38 |
credentialCount: 2,
|
| 39 |
healthyCredentialCount: 1,
|
| 40 |
unhealthyCredentialCount: 1,
|
|
|
|
| 35 |
channels: [
|
| 36 |
{
|
| 37 |
channelId: 'primary',
|
| 38 |
+
upstreamProxy: { configured: false },
|
| 39 |
credentialCount: 2,
|
| 40 |
healthyCredentialCount: 1,
|
| 41 |
unhealthyCredentialCount: 1,
|
src/lib/channel-router.test.ts
CHANGED
|
@@ -83,6 +83,61 @@ describe('parseChannelPoolConfig', () => {
|
|
| 83 |
]);
|
| 84 |
});
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
it('rejects invalid legacy upstream profile values instead of silently using the default profile', () => {
|
| 87 |
assert.throws(
|
| 88 |
() =>
|
|
@@ -236,6 +291,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 236 |
{
|
| 237 |
id: 'official',
|
| 238 |
baseUrl: 'https://api.openai.com/v1',
|
|
|
|
| 239 |
upstreamProfile: 'openai-compatible',
|
| 240 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 241 |
hasExtraHeaders: false,
|
|
@@ -247,6 +303,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 247 |
{
|
| 248 |
id: 'backup',
|
| 249 |
baseUrl: 'https://backup.example.com/v1',
|
|
|
|
| 250 |
upstreamProfile: 'openai-compatible',
|
| 251 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 252 |
hasExtraHeaders: false,
|
|
@@ -277,6 +334,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 277 |
{
|
| 278 |
id: 'matsca',
|
| 279 |
baseUrl: 'https://matsca.example.com/v1',
|
|
|
|
| 280 |
upstreamProfile: 'matsca',
|
| 281 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES.matsca,
|
| 282 |
hasExtraHeaders: true,
|
|
@@ -390,6 +448,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 390 |
{
|
| 391 |
id: 'custom',
|
| 392 |
baseUrl: 'https://custom.example.com/v1',
|
|
|
|
| 393 |
upstreamProfile: 'matsca',
|
| 394 |
effectiveProfile: config.credentials[0]?.providerProfile,
|
| 395 |
hasExtraHeaders: false,
|
|
@@ -1681,6 +1740,7 @@ describe('resolveEffectiveCredential', () => {
|
|
| 1681 |
requestApiKey: 'sk-browser',
|
| 1682 |
requestApiBaseUrl: '',
|
| 1683 |
legacyBaseUrl: 'https://legacy.example.com/v1',
|
|
|
|
| 1684 |
selectedCredential: {
|
| 1685 |
id: 'server#0',
|
| 1686 |
channelId: 'server',
|
|
@@ -1693,6 +1753,7 @@ describe('resolveEffectiveCredential', () => {
|
|
| 1693 |
assert.deepEqual(credential, {
|
| 1694 |
apiKey: 'sk-browser',
|
| 1695 |
baseUrl: 'https://legacy.example.com/v1',
|
|
|
|
| 1696 |
upstreamProfile: 'openai-compatible'
|
| 1697 |
});
|
| 1698 |
});
|
|
|
|
| 83 |
]);
|
| 84 |
});
|
| 85 |
|
| 86 |
+
it('applies the global proxy and lets a numbered channel override it', () => {
|
| 87 |
+
const config = parseChannelPoolConfig({
|
| 88 |
+
OPENAI_UPSTREAM_PROXY_URL: 'https://global-proxy.example:8443',
|
| 89 |
+
OPENAI_CHANNEL_1_ID: 'primary',
|
| 90 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1',
|
| 91 |
+
OPENAI_CHANNEL_1_API_KEYS: 'sk-primary',
|
| 92 |
+
OPENAI_CHANNEL_2_ID: 'backup',
|
| 93 |
+
OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1',
|
| 94 |
+
OPENAI_CHANNEL_2_API_KEYS: 'sk-backup',
|
| 95 |
+
OPENAI_CHANNEL_2_PROXY_URL: 'http://channel-proxy.example:8080'
|
| 96 |
+
});
|
| 97 |
+
|
| 98 |
+
assert.deepEqual(
|
| 99 |
+
config.credentials.map((credential) => ({
|
| 100 |
+
id: credential.id,
|
| 101 |
+
upstreamProxyUrl: credential.upstreamProxyUrl
|
| 102 |
+
})),
|
| 103 |
+
[
|
| 104 |
+
{ id: 'primary#0', upstreamProxyUrl: 'https://global-proxy.example:8443/' },
|
| 105 |
+
{ id: 'backup#0', upstreamProxyUrl: 'http://channel-proxy.example:8080/' }
|
| 106 |
+
]
|
| 107 |
+
);
|
| 108 |
+
const summary = getChannelPoolSummary(config);
|
| 109 |
+
assert.deepEqual(
|
| 110 |
+
summary.channels.map((channel) => ({ id: channel.id, upstreamProxy: channel.upstreamProxy })),
|
| 111 |
+
[
|
| 112 |
+
{ id: 'primary', upstreamProxy: { configured: true, protocol: 'https' } },
|
| 113 |
+
{ id: 'backup', upstreamProxy: { configured: true, protocol: 'http' } }
|
| 114 |
+
]
|
| 115 |
+
);
|
| 116 |
+
assert.equal(JSON.stringify(summary).includes('global-proxy.example'), false);
|
| 117 |
+
assert.equal(JSON.stringify(summary).includes('channel-proxy.example'), false);
|
| 118 |
+
});
|
| 119 |
+
|
| 120 |
+
it('rejects an invalid global or channel proxy URL explicitly', () => {
|
| 121 |
+
assert.throws(
|
| 122 |
+
() =>
|
| 123 |
+
parseChannelPoolConfig({
|
| 124 |
+
OPENAI_API_KEY: 'sk-legacy',
|
| 125 |
+
OPENAI_API_BASE_URL: 'https://legacy.example.com/v1',
|
| 126 |
+
OPENAI_UPSTREAM_PROXY_URL: 'ftp://proxy.example'
|
| 127 |
+
}),
|
| 128 |
+
/OPENAI_UPSTREAM_PROXY_URL/
|
| 129 |
+
);
|
| 130 |
+
assert.throws(
|
| 131 |
+
() =>
|
| 132 |
+
parseChannelPoolConfig({
|
| 133 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1',
|
| 134 |
+
OPENAI_CHANNEL_1_API_KEYS: 'sk-primary',
|
| 135 |
+
OPENAI_CHANNEL_1_PROXY_URL: 'http://proxy.example/path'
|
| 136 |
+
}),
|
| 137 |
+
/OPENAI_CHANNEL_1_PROXY_URL/
|
| 138 |
+
);
|
| 139 |
+
});
|
| 140 |
+
|
| 141 |
it('rejects invalid legacy upstream profile values instead of silently using the default profile', () => {
|
| 142 |
assert.throws(
|
| 143 |
() =>
|
|
|
|
| 291 |
{
|
| 292 |
id: 'official',
|
| 293 |
baseUrl: 'https://api.openai.com/v1',
|
| 294 |
+
upstreamProxy: { configured: false },
|
| 295 |
upstreamProfile: 'openai-compatible',
|
| 296 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 297 |
hasExtraHeaders: false,
|
|
|
|
| 303 |
{
|
| 304 |
id: 'backup',
|
| 305 |
baseUrl: 'https://backup.example.com/v1',
|
| 306 |
+
upstreamProxy: { configured: false },
|
| 307 |
upstreamProfile: 'openai-compatible',
|
| 308 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 309 |
hasExtraHeaders: false,
|
|
|
|
| 334 |
{
|
| 335 |
id: 'matsca',
|
| 336 |
baseUrl: 'https://matsca.example.com/v1',
|
| 337 |
+
upstreamProxy: { configured: false },
|
| 338 |
upstreamProfile: 'matsca',
|
| 339 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES.matsca,
|
| 340 |
hasExtraHeaders: true,
|
|
|
|
| 448 |
{
|
| 449 |
id: 'custom',
|
| 450 |
baseUrl: 'https://custom.example.com/v1',
|
| 451 |
+
upstreamProxy: { configured: false },
|
| 452 |
upstreamProfile: 'matsca',
|
| 453 |
effectiveProfile: config.credentials[0]?.providerProfile,
|
| 454 |
hasExtraHeaders: false,
|
|
|
|
| 1740 |
requestApiKey: 'sk-browser',
|
| 1741 |
requestApiBaseUrl: '',
|
| 1742 |
legacyBaseUrl: 'https://legacy.example.com/v1',
|
| 1743 |
+
legacyUpstreamProxyUrl: 'http://proxy.example:8080/',
|
| 1744 |
selectedCredential: {
|
| 1745 |
id: 'server#0',
|
| 1746 |
channelId: 'server',
|
|
|
|
| 1753 |
assert.deepEqual(credential, {
|
| 1754 |
apiKey: 'sk-browser',
|
| 1755 |
baseUrl: 'https://legacy.example.com/v1',
|
| 1756 |
+
upstreamProxyUrl: 'http://proxy.example:8080/',
|
| 1757 |
upstreamProfile: 'openai-compatible'
|
| 1758 |
});
|
| 1759 |
});
|
src/lib/channel-router.ts
CHANGED
|
@@ -29,6 +29,11 @@ import {
|
|
| 29 |
type ImageProviderManifest,
|
| 30 |
type ImageProviderManifestSummary
|
| 31 |
} from './image-upstream-provider-manifest';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
export type RoutingStrategy = 'sticky' | 'round_robin' | 'random';
|
| 34 |
|
|
@@ -37,6 +42,7 @@ export type ChannelCredential = {
|
|
| 37 |
channelId: string;
|
| 38 |
apiKey: string;
|
| 39 |
baseUrl?: string;
|
|
|
|
| 40 |
upstreamProfile: ImageUpstreamProfileId;
|
| 41 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 42 |
providerManifest?: ImageProviderManifestSummary;
|
|
@@ -61,6 +67,7 @@ export type ChannelPoolSummary = {
|
|
| 61 |
channels: Array<{
|
| 62 |
id: string;
|
| 63 |
baseUrl?: string;
|
|
|
|
| 64 |
upstreamProfile: ImageUpstreamProfileId;
|
| 65 |
effectiveProfile: ImageUpstreamProfile;
|
| 66 |
hasExtraHeaders: boolean;
|
|
@@ -167,6 +174,7 @@ export type ChannelHealthSnapshot = {
|
|
| 167 |
at: number;
|
| 168 |
channels: Array<{
|
| 169 |
channelId: string;
|
|
|
|
| 170 |
credentialCount: number;
|
| 171 |
healthyCredentialCount: number;
|
| 172 |
unhealthyCredentialCount: number;
|
|
@@ -186,6 +194,7 @@ export type ChannelRecoveryProbeCandidate = {
|
|
| 186 |
export type EffectiveCredential = {
|
| 187 |
apiKey?: string;
|
| 188 |
baseUrl?: string;
|
|
|
|
| 189 |
upstreamProfile: ImageUpstreamProfileId;
|
| 190 |
providerProfile?: ImageUpstreamProfile;
|
| 191 |
upstreamHeaders?: UpstreamRequestHeaders;
|
|
@@ -204,7 +213,7 @@ const DEFAULT_STRATEGY: RoutingStrategy = 'sticky';
|
|
| 204 |
const DEFAULT_FAILURE_COOLDOWN_MS = 30_000;
|
| 205 |
const VALID_STRATEGIES = new Set<RoutingStrategy>(['sticky', 'round_robin', 'random']);
|
| 206 |
const CHANNEL_KEY_PATTERN =
|
| 207 |
-
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|API_KEYS|UPSTREAM_PROFILE|PROVIDER_MANIFEST|REQUEST_MODES|REQUEST_MODE_PRIORITY|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON|FAILURE_COOLDOWN_MS)$/;
|
| 208 |
|
| 209 |
export function parseChannelPoolConfig(env: Record<string, string | undefined>): ChannelPoolConfig {
|
| 210 |
if (env.OPENAI_CHANNELS_JSON?.trim()) {
|
|
@@ -214,9 +223,10 @@ export function parseChannelPoolConfig(env: Record<string, string | undefined>):
|
|
| 214 |
);
|
| 215 |
}
|
| 216 |
|
|
|
|
| 217 |
const channelIndexes = readConfiguredChannelIndexes(env);
|
| 218 |
if (channelIndexes.length === 0) {
|
| 219 |
-
return parseLegacyConfig(env);
|
| 220 |
}
|
| 221 |
|
| 222 |
const strategy = readStrategy(env.OPENAI_ROUTING_STRATEGY, 'OPENAI_ROUTING_STRATEGY');
|
|
@@ -225,7 +235,7 @@ export function parseChannelPoolConfig(env: Record<string, string | undefined>):
|
|
| 225 |
'OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY'
|
| 226 |
);
|
| 227 |
const credentials = channelIndexes.flatMap((channelIndex) =>
|
| 228 |
-
parseNumberedChannel(env, channelIndex, requestModePriority)
|
| 229 |
);
|
| 230 |
|
| 231 |
if (credentials.length === 0) {
|
|
@@ -769,6 +779,7 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 769 |
const healthyCredentialCount = credentials.filter((credential) => credential.state === 'healthy').length;
|
| 770 |
return {
|
| 771 |
channelId,
|
|
|
|
| 772 |
credentialCount: credentials.length,
|
| 773 |
healthyCredentialCount,
|
| 774 |
unhealthyCredentialCount: credentials.length - healthyCredentialCount,
|
|
@@ -985,6 +996,7 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
|
|
| 985 |
{
|
| 986 |
id: string;
|
| 987 |
baseUrl?: string;
|
|
|
|
| 988 |
upstreamProfile: ImageUpstreamProfileId;
|
| 989 |
effectiveProfile: ImageUpstreamProfile;
|
| 990 |
hasExtraHeaders: boolean;
|
|
@@ -1005,6 +1017,7 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
|
|
| 1005 |
channels.set(credential.channelId, {
|
| 1006 |
id: credential.channelId,
|
| 1007 |
baseUrl: credential.baseUrl,
|
|
|
|
| 1008 |
upstreamProfile: credential.upstreamProfile,
|
| 1009 |
effectiveProfile: credential.providerProfile || IMAGE_UPSTREAM_PROFILES[credential.upstreamProfile],
|
| 1010 |
hasExtraHeaders: Boolean(credential.upstreamHeaders),
|
|
@@ -1085,6 +1098,7 @@ export function resolveEffectiveCredential(options: {
|
|
| 1085 |
requestApiKey: string;
|
| 1086 |
requestApiBaseUrl: string;
|
| 1087 |
legacyBaseUrl?: string;
|
|
|
|
| 1088 |
selectedCredential?: ChannelCredential;
|
| 1089 |
}): EffectiveCredential {
|
| 1090 |
if (options.requestApiKey) {
|
|
@@ -1094,6 +1108,7 @@ export function resolveEffectiveCredential(options: {
|
|
| 1094 |
return {
|
| 1095 |
apiKey: options.requestApiKey,
|
| 1096 |
baseUrl: options.requestApiBaseUrl || normalizeOptionalString(options.legacyBaseUrl),
|
|
|
|
| 1097 |
upstreamProfile: requestProfile.id
|
| 1098 |
};
|
| 1099 |
}
|
|
@@ -1101,6 +1116,9 @@ export function resolveEffectiveCredential(options: {
|
|
| 1101 |
return {
|
| 1102 |
apiKey: options.selectedCredential?.apiKey,
|
| 1103 |
baseUrl: options.selectedCredential?.baseUrl,
|
|
|
|
|
|
|
|
|
|
| 1104 |
upstreamProfile: options.selectedCredential?.upstreamProfile || DEFAULT_EFFECTIVE_PROFILE_ID,
|
| 1105 |
...(options.selectedCredential?.providerProfile
|
| 1106 |
? { providerProfile: options.selectedCredential.providerProfile }
|
|
@@ -1194,7 +1212,10 @@ export function toPublicChannelFailure(
|
|
| 1194 |
};
|
| 1195 |
}
|
| 1196 |
|
| 1197 |
-
function parseLegacyConfig(
|
|
|
|
|
|
|
|
|
|
| 1198 |
const apiKey = env.OPENAI_API_KEY?.trim();
|
| 1199 |
const baseUrl = normalizeOptionalString(env.OPENAI_API_BASE_URL);
|
| 1200 |
|
|
@@ -1227,6 +1248,7 @@ function parseLegacyConfig(env: Record<string, string | undefined>): ChannelPool
|
|
| 1227 |
channelId: 'default',
|
| 1228 |
apiKey,
|
| 1229 |
baseUrl,
|
|
|
|
| 1230 |
upstreamProfile: readImageUpstreamProfile({
|
| 1231 |
explicitProfile: rawProfile,
|
| 1232 |
channelId: 'default',
|
|
@@ -1242,11 +1264,14 @@ function parseLegacyConfig(env: Record<string, string | undefined>): ChannelPool
|
|
| 1242 |
function parseNumberedChannel(
|
| 1243 |
env: Record<string, string | undefined>,
|
| 1244 |
channelIndex: number,
|
| 1245 |
-
globalRequestModePriority?: ChannelRequestMode[]
|
|
|
|
| 1246 |
): ChannelCredential[] {
|
| 1247 |
const channelId = readOptionalEnv(env, `OPENAI_CHANNEL_${channelIndex}_ID`) || `channel-${channelIndex}`;
|
| 1248 |
const rawApiKeys = readRequiredEnv(env, `OPENAI_CHANNEL_${channelIndex}_API_KEYS`);
|
| 1249 |
const baseUrl = normalizeOptionalString(env[`OPENAI_CHANNEL_${channelIndex}_BASE_URL`]);
|
|
|
|
|
|
|
| 1250 |
const upstreamProfile = readChannelProfile(env, channelIndex, channelId, baseUrl);
|
| 1251 |
const upstreamHeaders = readChannelUpstreamHeaders(env, channelIndex, upstreamProfile);
|
| 1252 |
const providerManifest = readChannelProviderManifest(env, channelIndex, upstreamProfile);
|
|
@@ -1285,6 +1310,7 @@ function parseNumberedChannel(
|
|
| 1285 |
channelId,
|
| 1286 |
apiKey,
|
| 1287 |
baseUrl,
|
|
|
|
| 1288 |
upstreamProfile,
|
| 1289 |
...(upstreamHeaders ? { upstreamHeaders } : {}),
|
| 1290 |
...(providerManifest ? { providerManifest: createProviderManifestSummary(providerManifest) } : {}),
|
|
|
|
| 29 |
type ImageProviderManifest,
|
| 30 |
type ImageProviderManifestSummary
|
| 31 |
} from './image-upstream-provider-manifest';
|
| 32 |
+
import {
|
| 33 |
+
readOpenAIUpstreamProxyUrl,
|
| 34 |
+
summarizeOpenAIUpstreamProxy,
|
| 35 |
+
type UpstreamProxySummary
|
| 36 |
+
} from './openai-image-transport';
|
| 37 |
|
| 38 |
export type RoutingStrategy = 'sticky' | 'round_robin' | 'random';
|
| 39 |
|
|
|
|
| 42 |
channelId: string;
|
| 43 |
apiKey: string;
|
| 44 |
baseUrl?: string;
|
| 45 |
+
upstreamProxyUrl?: string;
|
| 46 |
upstreamProfile: ImageUpstreamProfileId;
|
| 47 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 48 |
providerManifest?: ImageProviderManifestSummary;
|
|
|
|
| 67 |
channels: Array<{
|
| 68 |
id: string;
|
| 69 |
baseUrl?: string;
|
| 70 |
+
upstreamProxy: UpstreamProxySummary;
|
| 71 |
upstreamProfile: ImageUpstreamProfileId;
|
| 72 |
effectiveProfile: ImageUpstreamProfile;
|
| 73 |
hasExtraHeaders: boolean;
|
|
|
|
| 174 |
at: number;
|
| 175 |
channels: Array<{
|
| 176 |
channelId: string;
|
| 177 |
+
upstreamProxy: UpstreamProxySummary;
|
| 178 |
credentialCount: number;
|
| 179 |
healthyCredentialCount: number;
|
| 180 |
unhealthyCredentialCount: number;
|
|
|
|
| 194 |
export type EffectiveCredential = {
|
| 195 |
apiKey?: string;
|
| 196 |
baseUrl?: string;
|
| 197 |
+
upstreamProxyUrl?: string;
|
| 198 |
upstreamProfile: ImageUpstreamProfileId;
|
| 199 |
providerProfile?: ImageUpstreamProfile;
|
| 200 |
upstreamHeaders?: UpstreamRequestHeaders;
|
|
|
|
| 213 |
const DEFAULT_FAILURE_COOLDOWN_MS = 30_000;
|
| 214 |
const VALID_STRATEGIES = new Set<RoutingStrategy>(['sticky', 'round_robin', 'random']);
|
| 215 |
const CHANNEL_KEY_PATTERN =
|
| 216 |
+
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|API_KEYS|UPSTREAM_PROFILE|PROVIDER_MANIFEST|REQUEST_MODES|REQUEST_MODE_PRIORITY|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON|FAILURE_COOLDOWN_MS|PROXY_URL)$/;
|
| 217 |
|
| 218 |
export function parseChannelPoolConfig(env: Record<string, string | undefined>): ChannelPoolConfig {
|
| 219 |
if (env.OPENAI_CHANNELS_JSON?.trim()) {
|
|
|
|
| 223 |
);
|
| 224 |
}
|
| 225 |
|
| 226 |
+
const globalUpstreamProxyUrl = readOpenAIUpstreamProxyUrl(env);
|
| 227 |
const channelIndexes = readConfiguredChannelIndexes(env);
|
| 228 |
if (channelIndexes.length === 0) {
|
| 229 |
+
return parseLegacyConfig(env, globalUpstreamProxyUrl);
|
| 230 |
}
|
| 231 |
|
| 232 |
const strategy = readStrategy(env.OPENAI_ROUTING_STRATEGY, 'OPENAI_ROUTING_STRATEGY');
|
|
|
|
| 235 |
'OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY'
|
| 236 |
);
|
| 237 |
const credentials = channelIndexes.flatMap((channelIndex) =>
|
| 238 |
+
parseNumberedChannel(env, channelIndex, requestModePriority, globalUpstreamProxyUrl)
|
| 239 |
);
|
| 240 |
|
| 241 |
if (credentials.length === 0) {
|
|
|
|
| 779 |
const healthyCredentialCount = credentials.filter((credential) => credential.state === 'healthy').length;
|
| 780 |
return {
|
| 781 |
channelId,
|
| 782 |
+
upstreamProxy: summarizeOpenAIUpstreamProxy(channelCredentials[0]?.upstreamProxyUrl),
|
| 783 |
credentialCount: credentials.length,
|
| 784 |
healthyCredentialCount,
|
| 785 |
unhealthyCredentialCount: credentials.length - healthyCredentialCount,
|
|
|
|
| 996 |
{
|
| 997 |
id: string;
|
| 998 |
baseUrl?: string;
|
| 999 |
+
upstreamProxy: UpstreamProxySummary;
|
| 1000 |
upstreamProfile: ImageUpstreamProfileId;
|
| 1001 |
effectiveProfile: ImageUpstreamProfile;
|
| 1002 |
hasExtraHeaders: boolean;
|
|
|
|
| 1017 |
channels.set(credential.channelId, {
|
| 1018 |
id: credential.channelId,
|
| 1019 |
baseUrl: credential.baseUrl,
|
| 1020 |
+
upstreamProxy: summarizeOpenAIUpstreamProxy(credential.upstreamProxyUrl),
|
| 1021 |
upstreamProfile: credential.upstreamProfile,
|
| 1022 |
effectiveProfile: credential.providerProfile || IMAGE_UPSTREAM_PROFILES[credential.upstreamProfile],
|
| 1023 |
hasExtraHeaders: Boolean(credential.upstreamHeaders),
|
|
|
|
| 1098 |
requestApiKey: string;
|
| 1099 |
requestApiBaseUrl: string;
|
| 1100 |
legacyBaseUrl?: string;
|
| 1101 |
+
legacyUpstreamProxyUrl?: string;
|
| 1102 |
selectedCredential?: ChannelCredential;
|
| 1103 |
}): EffectiveCredential {
|
| 1104 |
if (options.requestApiKey) {
|
|
|
|
| 1108 |
return {
|
| 1109 |
apiKey: options.requestApiKey,
|
| 1110 |
baseUrl: options.requestApiBaseUrl || normalizeOptionalString(options.legacyBaseUrl),
|
| 1111 |
+
...(options.legacyUpstreamProxyUrl ? { upstreamProxyUrl: options.legacyUpstreamProxyUrl } : {}),
|
| 1112 |
upstreamProfile: requestProfile.id
|
| 1113 |
};
|
| 1114 |
}
|
|
|
|
| 1116 |
return {
|
| 1117 |
apiKey: options.selectedCredential?.apiKey,
|
| 1118 |
baseUrl: options.selectedCredential?.baseUrl,
|
| 1119 |
+
...(options.selectedCredential?.upstreamProxyUrl
|
| 1120 |
+
? { upstreamProxyUrl: options.selectedCredential.upstreamProxyUrl }
|
| 1121 |
+
: {}),
|
| 1122 |
upstreamProfile: options.selectedCredential?.upstreamProfile || DEFAULT_EFFECTIVE_PROFILE_ID,
|
| 1123 |
...(options.selectedCredential?.providerProfile
|
| 1124 |
? { providerProfile: options.selectedCredential.providerProfile }
|
|
|
|
| 1212 |
};
|
| 1213 |
}
|
| 1214 |
|
| 1215 |
+
function parseLegacyConfig(
|
| 1216 |
+
env: Record<string, string | undefined>,
|
| 1217 |
+
globalUpstreamProxyUrl: string | undefined
|
| 1218 |
+
): ChannelPoolConfig {
|
| 1219 |
const apiKey = env.OPENAI_API_KEY?.trim();
|
| 1220 |
const baseUrl = normalizeOptionalString(env.OPENAI_API_BASE_URL);
|
| 1221 |
|
|
|
|
| 1248 |
channelId: 'default',
|
| 1249 |
apiKey,
|
| 1250 |
baseUrl,
|
| 1251 |
+
...(globalUpstreamProxyUrl ? { upstreamProxyUrl: globalUpstreamProxyUrl } : {}),
|
| 1252 |
upstreamProfile: readImageUpstreamProfile({
|
| 1253 |
explicitProfile: rawProfile,
|
| 1254 |
channelId: 'default',
|
|
|
|
| 1264 |
function parseNumberedChannel(
|
| 1265 |
env: Record<string, string | undefined>,
|
| 1266 |
channelIndex: number,
|
| 1267 |
+
globalRequestModePriority?: ChannelRequestMode[],
|
| 1268 |
+
globalUpstreamProxyUrl?: string
|
| 1269 |
): ChannelCredential[] {
|
| 1270 |
const channelId = readOptionalEnv(env, `OPENAI_CHANNEL_${channelIndex}_ID`) || `channel-${channelIndex}`;
|
| 1271 |
const rawApiKeys = readRequiredEnv(env, `OPENAI_CHANNEL_${channelIndex}_API_KEYS`);
|
| 1272 |
const baseUrl = normalizeOptionalString(env[`OPENAI_CHANNEL_${channelIndex}_BASE_URL`]);
|
| 1273 |
+
const upstreamProxyUrl =
|
| 1274 |
+
readOpenAIUpstreamProxyUrl(env, `OPENAI_CHANNEL_${channelIndex}_PROXY_URL`) ?? globalUpstreamProxyUrl;
|
| 1275 |
const upstreamProfile = readChannelProfile(env, channelIndex, channelId, baseUrl);
|
| 1276 |
const upstreamHeaders = readChannelUpstreamHeaders(env, channelIndex, upstreamProfile);
|
| 1277 |
const providerManifest = readChannelProviderManifest(env, channelIndex, upstreamProfile);
|
|
|
|
| 1310 |
channelId,
|
| 1311 |
apiKey,
|
| 1312 |
baseUrl,
|
| 1313 |
+
...(upstreamProxyUrl ? { upstreamProxyUrl } : {}),
|
| 1314 |
upstreamProfile,
|
| 1315 |
...(upstreamHeaders ? { upstreamHeaders } : {}),
|
| 1316 |
...(providerManifest ? { providerManifest: createProviderManifestSummary(providerManifest) } : {}),
|
src/lib/image-route-mode-handlers.ts
CHANGED
|
@@ -62,6 +62,7 @@ type CommonModeInput = {
|
|
| 62 |
storageMode: StorageMode;
|
| 63 |
apiBaseUrl?: string;
|
| 64 |
apiKey: string;
|
|
|
|
| 65 |
startedAtMs: number;
|
| 66 |
upstreamIdempotencyKey?: string;
|
| 67 |
clientRequestId?: string;
|
|
@@ -330,6 +331,7 @@ async function createResponsesImageStreamResponse(
|
|
| 330 |
storageMode: input.storageMode,
|
| 331 |
apiBaseUrl: input.apiBaseUrl,
|
| 332 |
apiKey: input.apiKey,
|
|
|
|
| 333 |
upstreamHeaders: input.upstreamHeaders,
|
| 334 |
model: input.model,
|
| 335 |
startedAtMs: input.startedAtMs,
|
|
@@ -372,6 +374,7 @@ async function createGenerateStreamResponse(
|
|
| 372 |
stream = await createImagesApiGenerateStream({
|
| 373 |
apiBaseUrl: input.apiBaseUrl,
|
| 374 |
apiKey: input.apiKey,
|
|
|
|
| 375 |
upstreamHeaders: input.upstreamHeaders,
|
| 376 |
idempotencyKey: input.upstreamIdempotencyKey,
|
| 377 |
abortSignal: input.abortSignal,
|
|
@@ -389,6 +392,7 @@ async function createGenerateStreamResponse(
|
|
| 389 |
storageMode: input.storageMode,
|
| 390 |
apiBaseUrl: input.apiBaseUrl,
|
| 391 |
apiKey: input.apiKey,
|
|
|
|
| 392 |
upstreamHeaders: input.upstreamHeaders,
|
| 393 |
model: input.model,
|
| 394 |
startedAtMs: input.startedAtMs,
|
|
@@ -532,6 +536,7 @@ async function createEditStreamResponse(input: CommonModeInput, options: EditOpt
|
|
| 532 |
storageMode: input.storageMode,
|
| 533 |
apiBaseUrl: input.apiBaseUrl,
|
| 534 |
apiKey: input.apiKey,
|
|
|
|
| 535 |
upstreamHeaders: input.upstreamHeaders,
|
| 536 |
model: input.model,
|
| 537 |
startedAtMs: input.startedAtMs,
|
|
@@ -596,6 +601,7 @@ export async function handleEditImageMode(
|
|
| 596 |
storageMode: input.storageMode,
|
| 597 |
apiBaseUrl: input.apiBaseUrl,
|
| 598 |
apiKey: input.apiKey,
|
|
|
|
| 599 |
upstreamHeaders: input.upstreamHeaders,
|
| 600 |
model: input.model,
|
| 601 |
startedAtMs: input.startedAtMs,
|
|
|
|
| 62 |
storageMode: StorageMode;
|
| 63 |
apiBaseUrl?: string;
|
| 64 |
apiKey: string;
|
| 65 |
+
upstreamProxyUrl?: string;
|
| 66 |
startedAtMs: number;
|
| 67 |
upstreamIdempotencyKey?: string;
|
| 68 |
clientRequestId?: string;
|
|
|
|
| 331 |
storageMode: input.storageMode,
|
| 332 |
apiBaseUrl: input.apiBaseUrl,
|
| 333 |
apiKey: input.apiKey,
|
| 334 |
+
upstreamProxyUrl: input.upstreamProxyUrl,
|
| 335 |
upstreamHeaders: input.upstreamHeaders,
|
| 336 |
model: input.model,
|
| 337 |
startedAtMs: input.startedAtMs,
|
|
|
|
| 374 |
stream = await createImagesApiGenerateStream({
|
| 375 |
apiBaseUrl: input.apiBaseUrl,
|
| 376 |
apiKey: input.apiKey,
|
| 377 |
+
upstreamProxyUrl: input.upstreamProxyUrl,
|
| 378 |
upstreamHeaders: input.upstreamHeaders,
|
| 379 |
idempotencyKey: input.upstreamIdempotencyKey,
|
| 380 |
abortSignal: input.abortSignal,
|
|
|
|
| 392 |
storageMode: input.storageMode,
|
| 393 |
apiBaseUrl: input.apiBaseUrl,
|
| 394 |
apiKey: input.apiKey,
|
| 395 |
+
upstreamProxyUrl: input.upstreamProxyUrl,
|
| 396 |
upstreamHeaders: input.upstreamHeaders,
|
| 397 |
model: input.model,
|
| 398 |
startedAtMs: input.startedAtMs,
|
|
|
|
| 536 |
storageMode: input.storageMode,
|
| 537 |
apiBaseUrl: input.apiBaseUrl,
|
| 538 |
apiKey: input.apiKey,
|
| 539 |
+
upstreamProxyUrl: input.upstreamProxyUrl,
|
| 540 |
upstreamHeaders: input.upstreamHeaders,
|
| 541 |
model: input.model,
|
| 542 |
startedAtMs: input.startedAtMs,
|
|
|
|
| 601 |
storageMode: input.storageMode,
|
| 602 |
apiBaseUrl: input.apiBaseUrl,
|
| 603 |
apiKey: input.apiKey,
|
| 604 |
+
upstreamProxyUrl: input.upstreamProxyUrl,
|
| 605 |
upstreamHeaders: input.upstreamHeaders,
|
| 606 |
model: input.model,
|
| 607 |
startedAtMs: input.startedAtMs,
|
src/lib/image-route-support.ts
CHANGED
|
@@ -437,6 +437,7 @@ export async function ensureOutputDirExists() {
|
|
| 437 |
async function resolveRequestActualCost(input: {
|
| 438 |
apiBaseUrl?: string;
|
| 439 |
apiKey: string;
|
|
|
|
| 440 |
model: string;
|
| 441 |
startedAtMs: number;
|
| 442 |
expectedImageCount: number;
|
|
@@ -453,6 +454,7 @@ async function resolveRequestActualCost(input: {
|
|
| 453 |
return resolveActualCost({
|
| 454 |
apiBaseUrl: input.apiBaseUrl,
|
| 455 |
apiKey: input.apiKey,
|
|
|
|
| 456 |
model: input.model,
|
| 457 |
startedAtMs: input.startedAtMs,
|
| 458 |
finishedAtMs,
|
|
@@ -463,6 +465,7 @@ async function resolveRequestActualCost(input: {
|
|
| 463 |
export async function resolveRequestActualCostSafely(input: {
|
| 464 |
apiBaseUrl?: string;
|
| 465 |
apiKey: string;
|
|
|
|
| 466 |
model: string;
|
| 467 |
startedAtMs: number;
|
| 468 |
expectedImageCount: number;
|
|
|
|
| 437 |
async function resolveRequestActualCost(input: {
|
| 438 |
apiBaseUrl?: string;
|
| 439 |
apiKey: string;
|
| 440 |
+
upstreamProxyUrl?: string;
|
| 441 |
model: string;
|
| 442 |
startedAtMs: number;
|
| 443 |
expectedImageCount: number;
|
|
|
|
| 454 |
return resolveActualCost({
|
| 455 |
apiBaseUrl: input.apiBaseUrl,
|
| 456 |
apiKey: input.apiKey,
|
| 457 |
+
...(input.upstreamProxyUrl ? { upstreamProxyUrl: input.upstreamProxyUrl } : {}),
|
| 458 |
model: input.model,
|
| 459 |
startedAtMs: input.startedAtMs,
|
| 460 |
finishedAtMs,
|
|
|
|
| 465 |
export async function resolveRequestActualCostSafely(input: {
|
| 466 |
apiBaseUrl?: string;
|
| 467 |
apiKey: string;
|
| 468 |
+
upstreamProxyUrl?: string;
|
| 469 |
model: string;
|
| 470 |
startedAtMs: number;
|
| 471 |
expectedImageCount: number;
|
src/lib/image-service.ts
CHANGED
|
@@ -170,6 +170,7 @@ export async function persistOpenAiImages(options: {
|
|
| 170 |
batchId?: string;
|
| 171 |
apiBaseUrl?: string;
|
| 172 |
apiKey?: string;
|
|
|
|
| 173 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 174 |
abortSignal?: AbortSignal;
|
| 175 |
}): Promise<PersistedOpenAiImage[]> {
|
|
@@ -191,6 +192,7 @@ export async function persistOpenAiImages(options: {
|
|
| 191 |
imageUrl: imageData.url,
|
| 192 |
apiBaseUrl: options.apiBaseUrl,
|
| 193 |
apiKey: options.apiKey,
|
|
|
|
| 194 |
upstreamHeaders: options.upstreamHeaders,
|
| 195 |
abortSignal: options.abortSignal
|
| 196 |
})
|
|
|
|
| 170 |
batchId?: string;
|
| 171 |
apiBaseUrl?: string;
|
| 172 |
apiKey?: string;
|
| 173 |
+
upstreamProxyUrl?: string;
|
| 174 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 175 |
abortSignal?: AbortSignal;
|
| 176 |
}): Promise<PersistedOpenAiImage[]> {
|
|
|
|
| 192 |
imageUrl: imageData.url,
|
| 193 |
apiBaseUrl: options.apiBaseUrl,
|
| 194 |
apiKey: options.apiKey,
|
| 195 |
+
upstreamProxyUrl: options.upstreamProxyUrl,
|
| 196 |
upstreamHeaders: options.upstreamHeaders,
|
| 197 |
abortSignal: options.abortSignal
|
| 198 |
})
|
src/lib/image-stream-collector.ts
CHANGED
|
@@ -39,6 +39,7 @@ export async function collectOpenAiImagesFromStream(
|
|
| 39 |
options: {
|
| 40 |
apiBaseUrl?: string;
|
| 41 |
apiKey?: string;
|
|
|
|
| 42 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 43 |
abortSignal?: AbortSignal;
|
| 44 |
onStreamingDegraded?: (reason: string) => void;
|
|
@@ -83,6 +84,7 @@ export async function collectOpenAiImagesFromStream(
|
|
| 83 |
imageUrl: normalizedEvent.imageUrl,
|
| 84 |
apiBaseUrl: options.apiBaseUrl,
|
| 85 |
apiKey: options.apiKey,
|
|
|
|
| 86 |
upstreamHeaders: options.upstreamHeaders,
|
| 87 |
abortSignal: options.abortSignal
|
| 88 |
})
|
|
|
|
| 39 |
options: {
|
| 40 |
apiBaseUrl?: string;
|
| 41 |
apiKey?: string;
|
| 42 |
+
upstreamProxyUrl?: string;
|
| 43 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 44 |
abortSignal?: AbortSignal;
|
| 45 |
onStreamingDegraded?: (reason: string) => void;
|
|
|
|
| 84 |
imageUrl: normalizedEvent.imageUrl,
|
| 85 |
apiBaseUrl: options.apiBaseUrl,
|
| 86 |
apiKey: options.apiKey,
|
| 87 |
+
upstreamProxyUrl: options.upstreamProxyUrl,
|
| 88 |
upstreamHeaders: options.upstreamHeaders,
|
| 89 |
abortSignal: options.abortSignal
|
| 90 |
})
|
src/lib/image-stream-service.ts
CHANGED
|
@@ -49,6 +49,7 @@ type SseWriter = ReturnType<typeof createSseWriter>;
|
|
| 49 |
type ResolveStreamCostInput = {
|
| 50 |
apiBaseUrl?: string;
|
| 51 |
apiKey: string;
|
|
|
|
| 52 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 53 |
model: string;
|
| 54 |
startedAtMs: number;
|
|
@@ -63,6 +64,7 @@ export type ImageStreamResponseOptions = {
|
|
| 63 |
storageMode: StorageMode;
|
| 64 |
apiBaseUrl?: string;
|
| 65 |
apiKey: string;
|
|
|
|
| 66 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 67 |
model: string;
|
| 68 |
startedAtMs: number;
|
|
@@ -218,6 +220,7 @@ async function downloadOptionalPartialImage(runtime: StreamRuntime, imageUrl: st
|
|
| 218 |
imageUrl,
|
| 219 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 220 |
apiKey: runtime.options.apiKey,
|
|
|
|
| 221 |
upstreamHeaders: runtime.options.upstreamHeaders,
|
| 222 |
abortSignal: runtime.options.abortSignal
|
| 223 |
});
|
|
@@ -257,6 +260,7 @@ async function emitCompletedImage(
|
|
| 257 |
imageUrl: normalizedEvent.imageUrl,
|
| 258 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 259 |
apiKey: runtime.options.apiKey,
|
|
|
|
| 260 |
upstreamHeaders: runtime.options.upstreamHeaders,
|
| 261 |
abortSignal: runtime.options.abortSignal
|
| 262 |
})
|
|
@@ -368,6 +372,7 @@ async function emitFallbackImages(runtime: StreamRuntime, result: OpenAI.Images.
|
|
| 368 |
imageUrl: image.url,
|
| 369 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 370 |
apiKey: runtime.options.apiKey,
|
|
|
|
| 371 |
upstreamHeaders: runtime.options.upstreamHeaders,
|
| 372 |
abortSignal: runtime.options.abortSignal
|
| 373 |
})
|
|
@@ -396,6 +401,7 @@ async function emitDoneEvent(runtime: StreamRuntime): Promise<boolean> {
|
|
| 396 |
const actualCost = await runtime.options.resolveActualCost({
|
| 397 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 398 |
apiKey: runtime.options.apiKey,
|
|
|
|
| 399 |
model: runtime.options.model,
|
| 400 |
startedAtMs: runtime.options.startedAtMs,
|
| 401 |
expectedImageCount: runtime.state.completedImages.length,
|
|
|
|
| 49 |
type ResolveStreamCostInput = {
|
| 50 |
apiBaseUrl?: string;
|
| 51 |
apiKey: string;
|
| 52 |
+
upstreamProxyUrl?: string;
|
| 53 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 54 |
model: string;
|
| 55 |
startedAtMs: number;
|
|
|
|
| 64 |
storageMode: StorageMode;
|
| 65 |
apiBaseUrl?: string;
|
| 66 |
apiKey: string;
|
| 67 |
+
upstreamProxyUrl?: string;
|
| 68 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 69 |
model: string;
|
| 70 |
startedAtMs: number;
|
|
|
|
| 220 |
imageUrl,
|
| 221 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 222 |
apiKey: runtime.options.apiKey,
|
| 223 |
+
upstreamProxyUrl: runtime.options.upstreamProxyUrl,
|
| 224 |
upstreamHeaders: runtime.options.upstreamHeaders,
|
| 225 |
abortSignal: runtime.options.abortSignal
|
| 226 |
});
|
|
|
|
| 260 |
imageUrl: normalizedEvent.imageUrl,
|
| 261 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 262 |
apiKey: runtime.options.apiKey,
|
| 263 |
+
upstreamProxyUrl: runtime.options.upstreamProxyUrl,
|
| 264 |
upstreamHeaders: runtime.options.upstreamHeaders,
|
| 265 |
abortSignal: runtime.options.abortSignal
|
| 266 |
})
|
|
|
|
| 372 |
imageUrl: image.url,
|
| 373 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 374 |
apiKey: runtime.options.apiKey,
|
| 375 |
+
upstreamProxyUrl: runtime.options.upstreamProxyUrl,
|
| 376 |
upstreamHeaders: runtime.options.upstreamHeaders,
|
| 377 |
abortSignal: runtime.options.abortSignal
|
| 378 |
})
|
|
|
|
| 401 |
const actualCost = await runtime.options.resolveActualCost({
|
| 402 |
apiBaseUrl: runtime.options.apiBaseUrl,
|
| 403 |
apiKey: runtime.options.apiKey,
|
| 404 |
+
upstreamProxyUrl: runtime.options.upstreamProxyUrl,
|
| 405 |
model: runtime.options.model,
|
| 406 |
startedAtMs: runtime.options.startedAtMs,
|
| 407 |
expectedImageCount: runtime.state.completedImages.length,
|
src/lib/image-url-result.test.ts
CHANGED
|
@@ -44,6 +44,35 @@ describe('downloadSameOriginImageAsBase64', () => {
|
|
| 44 |
assert.equal(observedAppSecret, 'app-secret');
|
| 45 |
});
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
it('enforces the remote image size limit when fetch returns no stream body', async () => {
|
| 48 |
let arrayBufferRead = false;
|
| 49 |
globalThis.fetch = async () =>
|
|
|
|
| 44 |
assert.equal(observedAppSecret, 'app-secret');
|
| 45 |
});
|
| 46 |
|
| 47 |
+
it('uses the shared header policy when the image download has no API key', async () => {
|
| 48 |
+
let observedProxyAuthorization: string | null = null;
|
| 49 |
+
let observedUserAgent: string | null = null;
|
| 50 |
+
let observedAppId: string | null = null;
|
| 51 |
+
globalThis.fetch = async (_url, init) => {
|
| 52 |
+
const headers = new Headers(init?.headers);
|
| 53 |
+
observedProxyAuthorization = headers.get('proxy-authorization');
|
| 54 |
+
observedUserAgent = headers.get('user-agent');
|
| 55 |
+
observedAppId = headers.get('x-app-id');
|
| 56 |
+
return new Response(Buffer.from('png'), {
|
| 57 |
+
status: 200,
|
| 58 |
+
headers: { 'content-type': 'image/png' }
|
| 59 |
+
});
|
| 60 |
+
};
|
| 61 |
+
|
| 62 |
+
await downloadSameOriginImageAsBase64({
|
| 63 |
+
imageUrl: '/generated/final.png',
|
| 64 |
+
apiBaseUrl: 'https://api.example.test/v1',
|
| 65 |
+
upstreamHeaders: {
|
| 66 |
+
'Proxy-Authorization': 'Basic c2VjcmV0',
|
| 67 |
+
'X-App-ID': 'app-id'
|
| 68 |
+
}
|
| 69 |
+
});
|
| 70 |
+
|
| 71 |
+
assert.equal(observedProxyAuthorization, null);
|
| 72 |
+
assert.equal(observedUserAgent, 'gpt-image-playground/2.1.0');
|
| 73 |
+
assert.equal(observedAppId, 'app-id');
|
| 74 |
+
});
|
| 75 |
+
|
| 76 |
it('enforces the remote image size limit when fetch returns no stream body', async () => {
|
| 77 |
let arrayBufferRead = false;
|
| 78 |
globalThis.fetch = async () =>
|
src/lib/image-url-result.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import { RequestValidationError } from './image-request-utils';
|
| 2 |
import { mergeUpstreamHeadersWithFixed, type UpstreamRequestHeaders } from './image-upstream-profile';
|
|
|
|
| 3 |
|
| 4 |
const MAX_REMOTE_IMAGE_BYTES = 25 * 1024 * 1024;
|
| 5 |
const REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
@@ -41,6 +42,7 @@ export async function downloadSameOriginImageAsBase64(input: {
|
|
| 41 |
imageUrl: string;
|
| 42 |
apiBaseUrl?: string;
|
| 43 |
apiKey?: string;
|
|
|
|
| 44 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 45 |
abortSignal?: AbortSignal;
|
| 46 |
}): Promise<string> {
|
|
@@ -50,10 +52,14 @@ export async function downloadSameOriginImageAsBase64(input: {
|
|
| 50 |
const abortListener = () => controller.abort();
|
| 51 |
input.abortSignal?.addEventListener('abort', abortListener, { once: true });
|
| 52 |
try {
|
| 53 |
-
const response = await
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
if (!response.ok) {
|
| 58 |
throw new RemoteImageResultError(`下载上游图片失败:HTTP ${response.status}。`);
|
| 59 |
}
|
|
@@ -86,9 +92,10 @@ function buildDownloadHeaders(
|
|
| 86 |
apiKey: string | undefined,
|
| 87 |
upstreamHeaders: UpstreamRequestHeaders | undefined
|
| 88 |
): UpstreamRequestHeaders | undefined {
|
| 89 |
-
const headers =
|
| 90 |
-
|
| 91 |
-
|
|
|
|
| 92 |
return Object.keys(headers).length > 0 ? headers : undefined;
|
| 93 |
}
|
| 94 |
|
|
|
|
| 1 |
import { RequestValidationError } from './image-request-utils';
|
| 2 |
import { mergeUpstreamHeadersWithFixed, type UpstreamRequestHeaders } from './image-upstream-profile';
|
| 3 |
+
import { fetchOpenAIUpstream } from './openai-image-transport';
|
| 4 |
|
| 5 |
const MAX_REMOTE_IMAGE_BYTES = 25 * 1024 * 1024;
|
| 6 |
const REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
|
|
|
|
| 42 |
imageUrl: string;
|
| 43 |
apiBaseUrl?: string;
|
| 44 |
apiKey?: string;
|
| 45 |
+
upstreamProxyUrl?: string;
|
| 46 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 47 |
abortSignal?: AbortSignal;
|
| 48 |
}): Promise<string> {
|
|
|
|
| 52 |
const abortListener = () => controller.abort();
|
| 53 |
input.abortSignal?.addEventListener('abort', abortListener, { once: true });
|
| 54 |
try {
|
| 55 |
+
const response = await fetchOpenAIUpstream(
|
| 56 |
+
url,
|
| 57 |
+
{
|
| 58 |
+
headers: buildDownloadHeaders(input.apiKey, input.upstreamHeaders),
|
| 59 |
+
signal: controller.signal
|
| 60 |
+
},
|
| 61 |
+
input.upstreamProxyUrl
|
| 62 |
+
);
|
| 63 |
if (!response.ok) {
|
| 64 |
throw new RemoteImageResultError(`下载上游图片失败:HTTP ${response.status}。`);
|
| 65 |
}
|
|
|
|
| 92 |
apiKey: string | undefined,
|
| 93 |
upstreamHeaders: UpstreamRequestHeaders | undefined
|
| 94 |
): UpstreamRequestHeaders | undefined {
|
| 95 |
+
const headers = mergeUpstreamHeadersWithFixed(
|
| 96 |
+
upstreamHeaders,
|
| 97 |
+
apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
|
| 98 |
+
);
|
| 99 |
return Object.keys(headers).length > 0 ? headers : undefined;
|
| 100 |
}
|
| 101 |
|
src/lib/images-api-stream.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { mergeUpstreamHeadersWithFixed, type UpstreamRequestHeaders } from './image-upstream-profile';
|
| 2 |
-
import { readImageUpstreamTimeoutMs } from './openai-image-transport';
|
| 3 |
import type OpenAI from 'openai';
|
| 4 |
|
| 5 |
export class ImagesApiStreamError extends Error {
|
|
@@ -15,6 +15,7 @@ export class ImagesApiStreamError extends Error {
|
|
| 15 |
type ImagesApiStreamInput = {
|
| 16 |
apiBaseUrl?: string;
|
| 17 |
apiKey: string;
|
|
|
|
| 18 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 19 |
idempotencyKey?: string;
|
| 20 |
abortSignal?: AbortSignal;
|
|
@@ -133,21 +134,25 @@ function readSseChunk(chunk: string): unknown | undefined {
|
|
| 133 |
}
|
| 134 |
|
| 135 |
export async function createImagesApiGenerateStream(input: ImagesApiStreamInput): Promise<AsyncIterable<unknown>> {
|
| 136 |
-
const { abortSignal, apiBaseUrl, apiKey, idempotencyKey, params, upstreamHeaders } = input;
|
| 137 |
const abortContext = createAbortContext({ abortSignal, timeoutMs: input.timeoutMs });
|
| 138 |
let response: Response;
|
| 139 |
try {
|
| 140 |
-
response = await
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
} catch (error) {
|
| 152 |
abortContext.cleanup();
|
| 153 |
throw error;
|
|
|
|
| 1 |
import { mergeUpstreamHeadersWithFixed, type UpstreamRequestHeaders } from './image-upstream-profile';
|
| 2 |
+
import { fetchOpenAIUpstream, readImageUpstreamTimeoutMs } from './openai-image-transport';
|
| 3 |
import type OpenAI from 'openai';
|
| 4 |
|
| 5 |
export class ImagesApiStreamError extends Error {
|
|
|
|
| 15 |
type ImagesApiStreamInput = {
|
| 16 |
apiBaseUrl?: string;
|
| 17 |
apiKey: string;
|
| 18 |
+
upstreamProxyUrl?: string;
|
| 19 |
upstreamHeaders?: UpstreamRequestHeaders;
|
| 20 |
idempotencyKey?: string;
|
| 21 |
abortSignal?: AbortSignal;
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
export async function createImagesApiGenerateStream(input: ImagesApiStreamInput): Promise<AsyncIterable<unknown>> {
|
| 137 |
+
const { abortSignal, apiBaseUrl, apiKey, idempotencyKey, params, upstreamHeaders, upstreamProxyUrl } = input;
|
| 138 |
const abortContext = createAbortContext({ abortSignal, timeoutMs: input.timeoutMs });
|
| 139 |
let response: Response;
|
| 140 |
try {
|
| 141 |
+
response = await fetchOpenAIUpstream(
|
| 142 |
+
buildImagesGenerateUrl(apiBaseUrl),
|
| 143 |
+
{
|
| 144 |
+
method: 'POST',
|
| 145 |
+
headers: mergeUpstreamHeadersWithFixed(upstreamHeaders, {
|
| 146 |
+
Authorization: `Bearer ${apiKey}`,
|
| 147 |
+
'Content-Type': 'application/json',
|
| 148 |
+
Accept: 'text/event-stream, application/json',
|
| 149 |
+
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
|
| 150 |
+
}),
|
| 151 |
+
signal: abortContext.signal,
|
| 152 |
+
body: JSON.stringify(params)
|
| 153 |
+
},
|
| 154 |
+
upstreamProxyUrl
|
| 155 |
+
);
|
| 156 |
} catch (error) {
|
| 157 |
abortContext.cleanup();
|
| 158 |
throw error;
|