diff --git a/.env.example b/.env.example
index 67a12c18639fbf7c66bd5dd1a8fc87ce2f1636a6..766b08d86fd464d23a733c6dc273d1b0d26fa800 100755
--- a/.env.example
+++ b/.env.example
@@ -10,6 +10,10 @@ OPENAI_API_KEY=
# 示例:https://api.openai.com/v1
OPENAI_API_BASE_URL=
+# 可选:服务端到图片上游的全局 HTTP(S) 代理。仅影响服务端出站请求,不影响浏览器访问本服务。
+# 仅支持无认证、无路径、无查询参数和无片段的 http:// 或 https:// 根代理地址;不支持 SOCKS。
+# OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
+
# 可选:服务端多渠道多 key 配置。配置任意 OPENAI_CHANNEL_N_* 后,会优先于 OPENAI_API_KEY。
# 页面右上角“API 设置”里手动填写的 API Key/API URL 仍然拥有最高优先级。
#
@@ -41,6 +45,7 @@ OPENAI_API_BASE_URL=
# 单渠道配置优先于 OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY,且不会扩大 REQUEST_MODES 白名单。
# 未配置时默认顺序:images-non-stream、images-sse、responses-non-stream、responses-sse。
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
+# - PROXY_URL 可选,覆盖 OPENAI_UPSTREAM_PROXY_URL,仅用于该渠道的服务端上游请求。
# - API Key 本身不要包含逗号。
#
# 示例:
@@ -50,6 +55,7 @@ OPENAI_API_BASE_URL=
# OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse
# OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse
# OPENAI_CHANNEL_1_FAILURE_COOLDOWN_MS=30000
+# OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080
# OPENAI_CHANNEL_1_USER_AGENT=gpt-image-playground/customer
# OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON={"X-Custom-Client":"customer"}
#
diff --git a/Dockerfile b/Dockerfile
index e5901eaed9629a84ecad5c95a11aa91a7f836aaf..35793d7e99cc691179ebb5b3fd9a0159f8eb0bd2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,6 +3,7 @@ WORKDIR /app
RUN apk add --no-cache python3 make g++ pkgconfig
COPY package.json package-lock.json ./
COPY scripts/check-install-script-policy.mjs scripts/dependency-installation.mjs scripts/npm-install-policy.mjs scripts/node-gyp-local-headers.cjs ./scripts/
+COPY vendor/brace-expansion-compat ./vendor/brace-expansion-compat
ENV NODE_OPTIONS=--require=/app/scripts/node-gyp-local-headers.cjs
RUN npm run install-scripts:check && npm run npm-install-policy:check && npm ci --strict-allow-scripts && npm run dependencies:check
ENV NODE_OPTIONS=
diff --git a/README.md b/README.md
index dffbf0e445d5ac76fcb89923d26bd8917b9f2e18..55fbca4738d4baa8ee81acbb4696305e87654aef 100755
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ app_port: 4783
本地 AI 图片创作工作台,面向中文内容运营、设计草图和自动化生图流程。支持 `gpt-image-2`、OpenAI 兼容图片接口、文生图、图生图、遮罩编辑、批量任务、历史复用、费用追踪和 Agent API。
-
+
## 快速开始
@@ -83,13 +83,13 @@ start-windows.bat
遮罩编辑示例:
-
+
历史与费用示例:
-
+
## 配置
@@ -99,6 +99,7 @@ start-windows.bat
| 场景 | 变量 | 说明 |
| ------------------ | --------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| 默认上游 | `OPENAI_API_KEY`、`OPENAI_API_BASE_URL` | 服务端默认 OpenAI 或兼容接口配置。页面 `API 设置` 优先级更高。 |
+| 上游代理 | `OPENAI_UPSTREAM_PROXY_URL`、`OPENAI_CHANNEL_N_PROXY_URL` | 可选。只用于服务端到图片上游的出站请求;渠道级地址优先于全局地址。仅接受无认证、无路径、无查询参数和无片段的 `http://` / `https://` 根代理地址,不支持 SOCKS。运行态和 Agent 诊断只公开是否启用及协议,不公开代理主机或端口。 |
| 页面访问码 | `APP_PASSWORD` | 设置后访问页面和受保护图片需要访问码。公网部署建议开启。 |
| Agent 鉴权 | `AGENT_API_TOKEN` | 设置后 `/api/agent/*` 需要 Bearer token。 |
| 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
OPENAI_CHANNEL_3_UPSTREAM_PROFILE=matsca
```
+服务端上游代理可按全局或渠道单独配置:
+
+```dotenv
+# 所有未单独覆盖的上游渠道使用此代理。
+OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
+
+# 仅覆盖渠道 2,优先级高于全局代理。
+OPENAI_CHANNEL_2_PROXY_URL=https://channel-proxy.internal:8443
+```
+
+代理仅作用于服务端到上游的 OpenAI/兼容 API、上游 SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志请求,不改变浏览器到本服务的连接,也不使用浏览器系统代理。代理 URL 只能是无认证的 `http://` 或 `https://` 根地址,不能包含 SOCKS 协议、用户名密码、路径、查询参数或片段。修改代理环境变量后必须重启服务或重新部署。`GET /api/runtime-capabilities`、Agent capabilities 和渠道健康诊断只显示 `configured` 与 `protocol`,不会返回代理主机或端口。
+
优先级:
```text
diff --git a/docs/deployment/huggingface-space-free.md b/docs/deployment/huggingface-space-free.md
index 1f58da8211c42a00da1024188dc7456591373772..ee05b030c9897205c560024e8ff2007130cb08e1 100644
--- a/docs/deployment/huggingface-space-free.md
+++ b/docs/deployment/huggingface-space-free.md
@@ -157,12 +157,16 @@ AGENT_PUBLIC_BASE_URL=https://-.hf.space
```dotenv
OPENAI_API_KEY=
OPENAI_API_BASE_URL=https://api.openai.com/v1
+# 可选:仅服务端到上游的无认证 HTTP(S) 代理。
+OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal:8080
APP_PASSWORD=
AGENT_API_TOKEN=
```
`OPENAI_API_BASE_URL` 和 `OPENAI_CHANNEL_N_BASE_URL` 必须是无凭据、无查询参数和无片段的 `http` 或 `https` 绝对地址,通常以 `/v1` 结尾。公网 Space 推荐使用 `https` 上游;只有内网、专用代理或已确认的兼容渠道需要 `http` 时才配置 `http`。
+`OPENAI_UPSTREAM_PROXY_URL` 只影响 Space 服务端到上游 API 的出站连接,不影响用户浏览器访问 Space。它仅接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS。多渠道部署可用 `OPENAI_CHANNEL_N_PROXY_URL` 覆盖全局代理,渠道级值优先。代理地址即使不含凭据也建议作为 Space Secret 管理;修改后需要重新启动或重新部署 Space。运行态和 Agent 诊断只公开是否配置及协议,不公开主机或端口。
+
公网部署建议至少设置访问码 `APP_PASSWORD` 和 `AGENT_API_TOKEN`。如果不设置 `APP_PASSWORD`,任何人都可以打开网页并消耗服务端 API Key。
如果要把这个 Space 当成客户可见的公网服务,`npm run doctor:hf-space` 的 `remote-secrets` 必须通过,且应同时看到 `APP_PASSWORD` 和 `AGENT_API_TOKEN` 已配置。没有这两个值时,只适合本地或受控内网试用,不适合直接给客户公开。
@@ -174,6 +178,8 @@ OPENAI_ROUTING_STRATEGY=round_robin
OPENAI_CHANNEL_1_ID=official
OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
OPENAI_CHANNEL_1_API_KEYS=,
+# 可选:仅覆盖此渠道的全局上游代理。
+OPENAI_CHANNEL_1_PROXY_URL=http://channel-proxy.internal:8080
```
## 手机网页使用
diff --git a/package-lock.json b/package-lock.json
index 0724219a0ac2c0645c258a3cea73247104244c86..17474f8d4e40ee8964a9eb76587c75d0dff42f4d 100755
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,11 +32,12 @@
"next-themes": "^0.4.6",
"openai": "^6.34.0",
"pg": "^8.20.0",
- "postcss": "^8.5.14",
+ "postcss": "^8.5.18",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.35.3",
- "tailwind-merge": "^3.2.0"
+ "tailwind-merge": "^3.2.0",
+ "undici": "^7.21.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -48,6 +49,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "brace-expansion": "file:vendor/brace-expansion-compat",
"eslint": "^9",
"eslint-config-next": "^16.2.10",
"happy-dom": "^20.10.6",
@@ -3131,6 +3133,72 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.4",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.2",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -3220,6 +3288,22 @@
}
}
},
+ "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/minimatch": {
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.2"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
@@ -3514,29 +3598,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
- "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
- "version": "4.0.4",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
- "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
- "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
- "version": "5.0.7",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
- "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "balanced-match": "^4.0.2"
- },
- "engines": {
- "node": "18 || 20 || >=22"
- }
- },
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
@@ -4134,11 +4195,14 @@
}
},
"node_modules/balanced-match": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
- "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
@@ -4166,14 +4230,21 @@
}
},
"node_modules/brace-expansion": {
- "version": "1.1.16",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
- "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
+ "resolved": "vendor/brace-expansion-compat",
+ "link": true
+ },
+ "node_modules/brace-expansion-modern": {
+ "name": "brace-expansion",
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
+ "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0",
- "concat-map": "0.0.1"
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
}
},
"node_modules/braces": {
@@ -4380,13 +4451,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/concat-map": {
- "version": "0.0.1",
- "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
- "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -6471,6 +6535,27 @@
"lightningcss-win32-x64-msvc": "1.32.0"
}
},
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
"node_modules/lightningcss-darwin-arm64": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
@@ -6697,6 +6782,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/lodash-es": {
+ "version": "4.18.1",
+ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
+ "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6736,6 +6828,16 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
@@ -6824,9 +6926,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
- "version": "3.3.11",
- "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
- "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"funding": [
{
"type": "github",
@@ -6927,6 +7029,15 @@
"react-dom": "^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.9.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
+ "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
@@ -7162,6 +7273,23 @@
"node": ">=6"
}
},
+ "node_modules/parse-imports-exports": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
+ "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parse-statements": "1.0.11"
+ }
+ },
+ "node_modules/parse-statements": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz",
+ "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -7308,9 +7436,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.14",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
- "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
+ "version": "8.5.23",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
+ "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -7327,7 +7455,7 @@
],
"license": "MIT",
"dependencies": {
- "nanoid": "^3.3.11",
+ "nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -8534,6 +8662,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
@@ -8856,160 +8993,16 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
- "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/brace-expansion": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
- "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
+ "vendor/brace-expansion-compat": {
+ "name": "brace-expansion",
+ "version": "5.0.8",
"dev": true,
"license": "MIT",
"dependencies": {
- "balanced-match": "^1.0.0"
- }
- },
- "node_modules/@trivago/prettier-plugin-sort-imports/node_modules/minimatch": {
- "version": "9.0.9",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
- "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "brace-expansion": "^2.0.2"
+ "brace-expansion-modern": "npm:brace-expansion@5.0.8"
},
"engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/lodash-es": {
- "version": "4.18.1",
- "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
- "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/parse-imports-exports": {
- "version": "0.2.4",
- "resolved": "https://registry.npmjs.org/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz",
- "integrity": "sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "parse-statements": "1.0.11"
- }
- },
- "node_modules/parse-statements": {
- "version": "1.0.11",
- "resolved": "https://registry.npmjs.org/parse-statements/-/parse-statements-1.0.11.tgz",
- "integrity": "sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
- "version": "1.11.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@emnapi/wasi-threads": "1.2.2",
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
- "version": "1.11.1",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
- "version": "1.2.2",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
- "version": "1.1.4",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "@tybys/wasm-util": "^0.10.1"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/Brooooooklyn"
- },
- "peerDependencies": {
- "@emnapi/core": "^1.7.1",
- "@emnapi/runtime": "^1.7.1"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
- "version": "0.10.2",
- "dev": true,
- "inBundle": true,
- "license": "MIT",
- "optional": true,
- "dependencies": {
- "tslib": "^2.4.0"
- }
- },
- "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
- "version": "2.8.1",
- "dev": true,
- "inBundle": true,
- "license": "0BSD",
- "optional": true
- },
- "node_modules/lightningcss-android-arm64": {
- "version": "1.32.0",
- "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
- "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
- "cpu": [
- "arm64"
- ],
- "dev": true,
- "license": "MPL-2.0",
- "optional": true,
- "os": [
- "android"
- ],
- "engines": {
- "node": ">= 12.0.0"
- },
- "funding": {
- "type": "opencollective",
- "url": "https://opencollective.com/parcel"
- }
- },
- "node_modules/magic-string": {
- "version": "0.30.21",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.5"
- }
- },
- "node_modules/node-addon-api": {
- "version": "8.9.0",
- "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
- "integrity": "sha512-ekZMeaaIzSQTSpr7X2X3iJM7lTzgnx8ahAG9pJfT/7+14mlEM8ZYQ9cgCDvSSRbReFK0oHli3WrZdCiRsgAT9Q==",
- "license": "MIT",
- "engines": {
- "node": "^18 || ^20 || >= 21"
+ "node": ">=20"
}
}
}
diff --git a/package.json b/package.json
index bc4d647fa0030a3821c0a74501e861df233766a7..20aec4ef3ed577a211c0571a924fad2e83c1af67 100755
--- a/package.json
+++ b/package.json
@@ -67,11 +67,12 @@
"next-themes": "^0.4.6",
"openai": "^6.34.0",
"pg": "^8.20.0",
- "postcss": "^8.5.14",
+ "postcss": "^8.5.18",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sharp": "^0.35.3",
- "tailwind-merge": "^3.2.0"
+ "tailwind-merge": "^3.2.0",
+ "undici": "^7.21.0"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -83,6 +84,7 @@
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "brace-expansion": "file:vendor/brace-expansion-compat",
"eslint": "^9",
"eslint-config-next": "^16.2.10",
"happy-dom": "^20.10.6",
@@ -97,7 +99,8 @@
"node": ">=22.15.0"
},
"overrides": {
- "postcss": "^8.5.14",
+ "postcss": "^8.5.18",
+ "brace-expansion": "$brace-expansion",
"sharp": "$sharp"
}
}
diff --git a/public/hf-space-deploy-marker.json b/public/hf-space-deploy-marker.json
index 8c4d3f6d3cd096d786cc2da86f38e439d4e4b9a4..1481bc733eeea73a1b4404cbefa64f03af4e9b19 100644
--- a/public/hf-space-deploy-marker.json
+++ b/public/hf-space-deploy-marker.json
@@ -1,6 +1,6 @@
{
"schema_version": 1,
- "local_sha": "7db5c07cd25ba69e829f2561814fec0a1a73e7e8",
- "created_at": "2026-07-23T14:28:33.665Z",
- "deploy_id": "e28ac5a2-35c9-4be8-a553-2f3632c02d2e"
+ "local_sha": "4770498acf64bbcda373c6a220f5b5781cef6136",
+ "created_at": "2026-07-25T04:50:43.340Z",
+ "deploy_id": "28984fcf-867f-419f-bc4c-22acd7ba92c9"
}
diff --git a/scripts/agent-skill-scripts.test.mjs b/scripts/agent-skill-scripts.test.mjs
index ba84482b6e8d4c435b519495f65807da995cd9ff..18b74985159e240eb8fd8159b0da7a7743cacd10 100644
--- a/scripts/agent-skill-scripts.test.mjs
+++ b/scripts/agent-skill-scripts.test.mjs
@@ -4399,10 +4399,12 @@ describe('Agent skill script argument validation', () => {
const apiReference = readFileSync(join(skillRoot, 'references/api.md'), 'utf8');
assert.match(skillText, /必须优先运行本 Skill 内置 scripts\/generate-image\.mjs/);
+ assert.match(skillText, /scripts\/channel-capability-matrix\.mjs/);
assert.match(skillText, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/);
assert.match(openAiYaml, /先选择并运行内置脚本/);
assert.match(openAiYaml, /不要临时编写 API 调用脚本/);
assert.match(apiReference, /先使用这些内置脚本/);
+ assert.match(apiReference, /scripts\/channel-capability-matrix\.mjs/);
assert.match(apiReference, /不要临时编写 Node\/Python\/shell 脚本、curl 命令或手写 fetch\/FormData/);
});
diff --git a/scripts/brace-expansion-compat.test.mjs b/scripts/brace-expansion-compat.test.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..bd89e20ccbd18590e53ba756de6a7567379d1799
--- /dev/null
+++ b/scripts/brace-expansion-compat.test.mjs
@@ -0,0 +1,35 @@
+import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
+import { test } from 'node:test';
+
+const require = createRequire(import.meta.url);
+
+test('brace expansion compatibility facade supports legacy CommonJS consumers', () => {
+ const braceExpansion = require('brace-expansion');
+
+ assert.equal(typeof braceExpansion, 'function');
+ assert.equal(typeof braceExpansion.expand, 'function');
+ assert.deepEqual(braceExpansion('image-{a,b}.png'), ['image-a.png', 'image-b.png']);
+ assert.deepEqual(braceExpansion.expand('image-{a,b}.png'), ['image-a.png', 'image-b.png']);
+});
+
+test('brace expansion compatibility facade supports current ESM consumers', async () => {
+ const braceExpansion = await import('brace-expansion');
+
+ assert.equal(typeof braceExpansion.default, 'function');
+ assert.equal(typeof braceExpansion.expand, 'function');
+ assert.ok(braceExpansion.EXPANSION_MAX > 0);
+ assert.deepEqual(braceExpansion.expand('image-{a,b}.png', { max: 1 }), ['image-a.png']);
+});
+
+test('brace expansion compatibility facade supports installed minimatch consumer versions', () => {
+ const legacyMinimatch = require('minimatch');
+ const sortImportsRequire = createRequire(require.resolve('@trivago/prettier-plugin-sort-imports/package.json'));
+ const typeScriptEstreeRequire = createRequire(require.resolve('@typescript-eslint/typescript-estree/package.json'));
+ const sortImportsMinimatch = sortImportsRequire('minimatch');
+ const typeScriptEstreeMinimatch = typeScriptEstreeRequire('minimatch');
+
+ assert.equal(legacyMinimatch('src/image.ts', 'src/*.{ts,tsx}'), true);
+ assert.equal(sortImportsMinimatch.minimatch('src/image.ts', 'src/*.{ts,tsx}'), true);
+ assert.equal(typeScriptEstreeMinimatch.minimatch('src/image.ts', 'src/*.{ts,tsx}'), true);
+});
diff --git a/scripts/channel-capability-matrix.test.mjs b/scripts/channel-capability-matrix.test.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..f54ba03a12da3327e67f8bf26dbb0e83ac4335c6
--- /dev/null
+++ b/scripts/channel-capability-matrix.test.mjs
@@ -0,0 +1,562 @@
+import assert from 'node:assert/strict';
+import { spawn } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { describe, it } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+import {
+ FIXTURE_IMAGE_BASE64,
+ createFixtureServer
+} from './local-image-upstream-fixture.mjs';
+import {
+ buildChannelEnvConfig,
+ buildRedactedChannelEnvPreview
+} from '../skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs';
+
+const repoRoot = fileURLToPath(new URL('..', import.meta.url));
+const matrixScript = join(repoRoot, 'skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs');
+const testApiKey = 'test-upstream-token';
+const testResponsesModel = 'gpt-5.4';
+
+describe('channel capability matrix Skill script', () => {
+ it('writes a private directly usable channel configuration after all modes pass', async () => {
+ const fixture = await startServer(createFixtureServer());
+ const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-'));
+ const outputPath = join(tempRoot, 'channel.env');
+ try {
+ const result = await runMatrix(
+ [
+ '--base-url',
+ `${fixture.baseUrl}/v1`,
+ '--responses-model',
+ testResponsesModel,
+ '--allow-billable',
+ '--write-env-file',
+ outputPath,
+ '--channel-id',
+ 'fixture-upstream',
+ '--timeout-ms',
+ '5000'
+ ],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+
+ assert.equal(result.status, 0);
+ assert.equal(result.stderr.trim(), '');
+ assert.doesNotMatch(result.stdout, new RegExp(testApiKey));
+ const report = JSON.parse(result.stdout);
+ assert.equal(report.ok, true);
+ assert.deepEqual(report.matrix.passed, [
+ 'images-non-stream',
+ 'images-sse',
+ 'responses-non-stream',
+ 'responses-sse'
+ ]);
+ assert.deepEqual(report.matrix.failed, []);
+ assert.equal(report.configuration.ready, true);
+ assert.equal(report.write.written, true);
+ assert.deepEqual(report.configuration.env_preview, [
+ 'OPENAI_CHANNEL_1_ID=fixture-upstream',
+ `OPENAI_CHANNEL_1_BASE_URL=${fixture.baseUrl}/v1`,
+ 'OPENAI_CHANNEL_1_API_KEYS=[redacted]',
+ 'OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse',
+ 'OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse',
+ 'IMAGE_GENERATION_BACKEND=images-api',
+ 'IMAGE_STREAMING_STRATEGY=auto',
+ 'ENABLE_RESPONSES_IMAGE_BACKEND=true',
+ `OPENAI_RESPONSES_API_MODEL=${testResponsesModel}`
+ ]);
+
+ const content = readFileSync(outputPath, 'utf8');
+ assert.match(content, /OPENAI_CHANNEL_1_ID=fixture-upstream/);
+ assert.match(content, new RegExp(`OPENAI_CHANNEL_1_BASE_URL=${escapeRegExp(`${fixture.baseUrl}/v1`)}`));
+ assert.match(content, new RegExp(`OPENAI_CHANNEL_1_API_KEYS=${testApiKey}`));
+ assert.match(content, /OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse/);
+ assert.match(content, /OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY=images-non-stream,images-sse,responses-non-stream,responses-sse/);
+ assert.match(content, /IMAGE_GENERATION_BACKEND=images-api/);
+ assert.match(content, /IMAGE_STREAMING_STRATEGY=auto/);
+ assert.match(content, /ENABLE_RESPONSES_IMAGE_BACKEND=true/);
+ assert.match(content, new RegExp(`OPENAI_RESPONSES_API_MODEL=${testResponsesModel}`));
+ assert.equal(statSync(outputPath).mode & 0o777, 0o600);
+
+ const resolvedConfig = await resolveGeneratedConfig(outputPath);
+ assert.equal(resolvedConfig.status, 0);
+ assert.equal(resolvedConfig.stderr.trim(), '');
+ assert.deepEqual(resolvedConfig.value, {
+ channel: {
+ id: 'fixture-upstream',
+ base_url: `${fixture.baseUrl}/v1`,
+ request_modes: [
+ 'images-non-stream',
+ 'images-sse',
+ 'responses-non-stream',
+ 'responses-sse'
+ ],
+ request_mode_priority: [
+ 'images-non-stream',
+ 'images-sse',
+ 'responses-non-stream',
+ 'responses-sse'
+ ]
+ },
+ image_backend: 'images-api',
+ streaming_strategy: 'auto'
+ });
+
+ const refused = await runMatrix(
+ [
+ '--base-url',
+ `${fixture.baseUrl}/v1`,
+ '--responses-model',
+ testResponsesModel,
+ '--allow-billable',
+ '--write-env-file',
+ outputPath,
+ '--channel-id',
+ 'fixture-upstream',
+ '--timeout-ms',
+ '5000'
+ ],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+ assert.equal(refused.status, 2);
+ assert.match(refused.stderr, /已存在/);
+ assert.equal(refused.stdout.trim(), '');
+
+ const overwritten = await runMatrix(
+ [
+ '--base-url',
+ `${fixture.baseUrl}/v1`,
+ '--responses-model',
+ testResponsesModel,
+ '--allow-billable',
+ '--write-env-file',
+ outputPath,
+ '--channel-id',
+ 'fixture-upstream',
+ '--timeout-ms',
+ '5000',
+ '--overwrite'
+ ],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+ assert.equal(overwritten.status, 0);
+ assert.equal(JSON.parse(overwritten.stdout).write.written, true);
+ assert.equal(statSync(outputPath).mode & 0o777, 0o600);
+ } finally {
+ await fixture.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('writes only the verified Images API mode and preserves fixed probe order', async () => {
+ const calls = [];
+ const fixture = await startServer(
+ createServer(async (request, response) => {
+ const url = new URL(request.url || '/', 'http://fixture.local');
+ if (request.method === 'GET' && url.pathname === '/v1/models') {
+ calls.push({ method: request.method, path: url.pathname });
+ sendJson(response, 200, { data: [{ id: 'gpt-image-2' }, { id: testResponsesModel }] });
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
+ const body = await readJsonBody(request);
+ calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
+ if (body.stream === true) {
+ sendJson(response, 502, { error: { message: 'images sse unavailable' } });
+ return;
+ }
+ sendJson(response, 200, { data: [{ b64_json: FIXTURE_IMAGE_BASE64 }] });
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/v1/responses') {
+ const body = await readJsonBody(request);
+ calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
+ sendJson(response, 503, { error: { message: 'responses unavailable' } });
+ return;
+ }
+ sendJson(response, 404, { error: { message: 'unknown route' } });
+ })
+ );
+ const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-partial-'));
+ const outputPath = join(tempRoot, 'partial.env');
+ try {
+ const result = await runMatrix(
+ [
+ '--base-url',
+ `${fixture.baseUrl}/v1`,
+ '--responses-model',
+ testResponsesModel,
+ '--allow-billable',
+ '--write-env-file',
+ outputPath,
+ '--channel-index',
+ '2',
+ '--channel-id',
+ 'images-only',
+ '--timeout-ms',
+ '5000'
+ ],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+
+ assert.equal(result.status, 0);
+ const report = JSON.parse(result.stdout);
+ assert.deepEqual(report.matrix.passed, ['images-non-stream']);
+ assert.deepEqual(report.matrix.failed, ['images-sse', 'responses-non-stream', 'responses-sse']);
+ assert.equal(report.matrix.coverage_complete, true);
+ assert.equal(report.configuration.ready, true);
+ assert.equal(report.write.written, true);
+ assert.deepEqual(calls, [
+ { method: 'GET', path: '/v1/models' },
+ { method: 'POST', path: '/v1/images/generations', stream: false },
+ { method: 'POST', path: '/v1/images/generations', stream: true },
+ { method: 'POST', path: '/v1/responses', stream: false },
+ { method: 'POST', path: '/v1/responses', stream: true }
+ ]);
+
+ const content = readFileSync(outputPath, 'utf8');
+ assert.match(content, /OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream/);
+ assert.match(content, /OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY=images-non-stream/);
+ assert.match(content, /IMAGE_GENERATION_BACKEND=images-api/);
+ assert.match(content, /IMAGE_STREAMING_STRATEGY=auto/);
+ assert.doesNotMatch(content, /ENABLE_RESPONSES_IMAGE_BACKEND/);
+ assert.doesNotMatch(content, /OPENAI_RESPONSES_API_MODEL/);
+ } finally {
+ await fixture.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('selects the Responses backend when no Images API request mode is usable', async () => {
+ const calls = [];
+ const fixture = await startServer(
+ createServer(async (request, response) => {
+ const url = new URL(request.url || '/', 'http://fixture.local');
+ if (request.method === 'GET' && url.pathname === '/v1/models') {
+ calls.push({ method: request.method, path: url.pathname });
+ sendJson(response, 200, { data: [{ id: 'gpt-image-2' }, { id: testResponsesModel }] });
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
+ const body = await readJsonBody(request);
+ calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
+ sendJson(response, 503, { error: { message: 'images unavailable' } });
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/v1/responses') {
+ const body = await readJsonBody(request);
+ calls.push({ method: request.method, path: url.pathname, stream: body.stream === true });
+ if (body.stream === true) {
+ sendJson(response, 503, { error: { message: 'responses sse unavailable' } });
+ return;
+ }
+ sendJson(response, 200, {
+ output: [
+ {
+ type: 'image_generation_call',
+ status: 'completed',
+ result: `data:image/png;base64,${FIXTURE_IMAGE_BASE64}`
+ }
+ ]
+ });
+ return;
+ }
+ sendJson(response, 404, { error: { message: 'unknown route' } });
+ })
+ );
+ const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-responses-only-'));
+ const outputPath = join(tempRoot, 'responses-only.env');
+ try {
+ const result = await runMatrix(
+ [
+ '--base-url',
+ `${fixture.baseUrl}/v1`,
+ '--responses-model',
+ testResponsesModel,
+ '--allow-billable',
+ '--write-env-file',
+ outputPath,
+ '--channel-index',
+ '3',
+ '--channel-id',
+ 'responses-only',
+ '--timeout-ms',
+ '5000'
+ ],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+
+ assert.equal(result.status, 0);
+ const report = JSON.parse(result.stdout);
+ assert.deepEqual(report.matrix.passed, ['responses-non-stream']);
+ assert.deepEqual(report.matrix.failed, ['images-non-stream', 'images-sse', 'responses-sse']);
+ assert.equal(report.configuration.ready, true);
+ assert.equal(report.configuration.image_backend, 'responses-image-generation');
+ assert.equal(report.configuration.streaming_strategy, 'auto');
+ assert.equal(report.write.written, true);
+ assert.deepEqual(calls, [
+ { method: 'GET', path: '/v1/models' },
+ { method: 'POST', path: '/v1/images/generations', stream: false },
+ { method: 'POST', path: '/v1/images/generations', stream: true },
+ { method: 'POST', path: '/v1/responses', stream: false },
+ { method: 'POST', path: '/v1/responses', stream: true }
+ ]);
+
+ const content = readFileSync(outputPath, 'utf8');
+ assert.match(content, /OPENAI_CHANNEL_3_REQUEST_MODES=responses-non-stream/);
+ assert.match(content, /OPENAI_CHANNEL_3_REQUEST_MODE_PRIORITY=responses-non-stream/);
+ assert.match(content, /IMAGE_GENERATION_BACKEND=responses-image-generation/);
+ assert.match(content, /IMAGE_STREAMING_STRATEGY=auto/);
+ assert.match(content, /ENABLE_RESPONSES_IMAGE_BACKEND=true/);
+ assert.match(content, new RegExp(`OPENAI_RESPONSES_API_MODEL=${testResponsesModel}`));
+
+ const resolvedConfig = await resolveGeneratedConfig(outputPath);
+ assert.equal(resolvedConfig.status, 0);
+ assert.equal(resolvedConfig.stderr.trim(), '');
+ assert.equal(resolvedConfig.value.channel.id, 'responses-only');
+ assert.deepEqual(resolvedConfig.value.channel.request_modes, ['responses-non-stream']);
+ assert.equal(resolvedConfig.value.image_backend, 'responses-image-generation');
+ assert.equal(resolvedConfig.value.streaming_strategy, 'auto');
+ } finally {
+ await fixture.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('refuses a remote URL-only result and leaves the target absent', async () => {
+ const fixture = await startServer(
+ createServer(async (request, response) => {
+ const url = new URL(request.url || '/', 'http://fixture.local');
+ if (request.method === 'GET' && url.pathname === '/v1/models') {
+ sendJson(response, 200, { data: [{ id: 'gpt-image-2' }, { id: testResponsesModel }] });
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/v1/images/generations') {
+ await readJsonBody(request);
+ sendJson(response, 200, { data: [{ url: 'https://cdn.example.test/generated.png' }] });
+ return;
+ }
+ if (request.method === 'POST' && url.pathname === '/v1/responses') {
+ await readJsonBody(request);
+ sendJson(response, 503, { error: { message: 'responses unavailable' } });
+ return;
+ }
+ sendJson(response, 404, { error: { message: 'unknown route' } });
+ })
+ );
+ const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-remote-url-'));
+ const outputPath = join(tempRoot, 'remote-url.env');
+ try {
+ const result = await runMatrix(
+ [
+ '--base-url',
+ `${fixture.baseUrl}/v1`,
+ '--responses-model',
+ testResponsesModel,
+ '--allow-billable',
+ '--write-env-file',
+ outputPath,
+ '--channel-id',
+ 'remote-url-only',
+ '--timeout-ms',
+ '5000'
+ ],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+
+ assert.equal(result.status, 1);
+ assert.doesNotMatch(result.stdout, new RegExp(testApiKey));
+ const report = JSON.parse(result.stdout);
+ assert.deepEqual(report.matrix.passed, []);
+ assert.equal(report.matrix.modes['images-non-stream'].has_remote_url_result, true);
+ assert.equal(report.configuration.ready, false);
+ assert.deepEqual(report.configuration.blocking_reasons, ['no_consumable_image_mode']);
+ assert.equal(report.write.written, false);
+ assert.equal(report.write.reason, 'configuration_not_ready');
+ assert.equal(existsSync(outputPath), false);
+ } finally {
+ await fixture.close();
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('requires explicit billable permission before it accepts a private output target', async () => {
+ const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-non-billable-'));
+ const outputPath = join(tempRoot, 'unverified.env');
+ try {
+ const result = await runMatrix(
+ ['--base-url', 'http://127.0.0.1:9/v1', '--write-env-file', outputPath],
+ { GPT_IMAGE_UPSTREAM_API_KEY: testApiKey }
+ );
+
+ assert.equal(result.status, 2);
+ assert.match(result.stderr, /--allow-billable/);
+ assert.equal(result.stdout.trim(), '');
+ assert.equal(existsSync(outputPath), false);
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('includes the exact allowlist required for a remote plain HTTP upstream', async () => {
+ const baseUrl = 'http://images.internal.example.test/v1';
+ const config = buildChannelEnvConfig({
+ channelIndex: 4,
+ channelId: 'plain-http',
+ baseUrl,
+ apiKey: testApiKey,
+ requestModes: ['images-non-stream']
+ });
+ const preview = buildRedactedChannelEnvPreview({
+ channelIndex: 4,
+ channelId: 'plain-http',
+ baseUrl,
+ requestModes: ['images-non-stream']
+ });
+
+ assert.match(config, new RegExp(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${escapeRegExp(baseUrl)}`));
+ assert.ok(preview.includes(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${baseUrl}`));
+ assert.doesNotMatch(config, /OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=http:\/\/127\.0\.0\.1/);
+
+ const tempRoot = mkdtempSync(join(tmpdir(), 'channel-capability-matrix-plain-http-'));
+ const outputPath = join(tempRoot, 'plain-http.env');
+ try {
+ writeFileSync(outputPath, config, { mode: 0o600 });
+ const resolvedConfig = await resolveGeneratedConfig(outputPath);
+ assert.equal(resolvedConfig.status, 0);
+ assert.equal(resolvedConfig.stderr.trim(), '');
+ assert.equal(resolvedConfig.value.channel.base_url, baseUrl);
+ assert.deepEqual(resolvedConfig.value.channel.request_modes, ['images-non-stream']);
+ assert.equal(resolvedConfig.value.image_backend, 'images-api');
+ } finally {
+ rmSync(tempRoot, { recursive: true, force: true });
+ }
+ });
+});
+
+async function startServer(server) {
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ const address = server.address();
+ assert.ok(address && typeof address === 'object');
+ return {
+ baseUrl: `http://127.0.0.1:${address.port}`,
+ close: () => closeServer(server)
+ };
+}
+
+function closeServer(server) {
+ return new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve();
+ });
+ });
+}
+
+function runMatrix(args, env) {
+ return new Promise((resolveResult) => {
+ const child = spawn(process.execPath, [matrixScript, ...args], {
+ cwd: repoRoot,
+ env: {
+ ...buildIsolatedEnvironment(),
+ ...env
+ },
+ stdio: ['ignore', 'pipe', 'pipe']
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.once('error', () => {
+ resolveResult({ status: undefined, stdout, stderr });
+ });
+ child.once('close', (status) => {
+ resolveResult({ status: status ?? undefined, stdout, stderr });
+ });
+ });
+}
+
+function resolveGeneratedConfig(envFilePath) {
+ const script = [
+ "import { parseChannelPoolConfig } from './src/lib/channel-router.ts';",
+ "import { readImageGenerationBackend, readImageStreamingStrategy } from './src/lib/image-upstream-strategy.ts';",
+ 'const formData = new FormData();',
+ 'const config = parseChannelPoolConfig(process.env);',
+ 'const credential = config.credentials[0];',
+ 'console.log(JSON.stringify({',
+ ' channel: {',
+ ' id: credential.channelId,',
+ ' base_url: credential.baseUrl,',
+ ' request_modes: credential.requestModes,',
+ ' request_mode_priority: credential.requestModePriority',
+ ' },',
+ ' image_backend: readImageGenerationBackend(formData),',
+ ' streaming_strategy: readImageStreamingStrategy(formData)',
+ '}));'
+ ].join('\n');
+ return new Promise((resolveResult) => {
+ const child = spawn(process.execPath, ['--env-file', envFilePath, '--import', 'tsx', '--input-type=module', '--eval', script], {
+ cwd: repoRoot,
+ env: buildIsolatedEnvironment(),
+ stdio: ['ignore', 'pipe', 'pipe']
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.once('error', () => {
+ resolveResult({ status: undefined, stderr, value: undefined });
+ });
+ child.once('close', (status) => {
+ let value;
+ try {
+ value = JSON.parse(stdout);
+ } catch {}
+ resolveResult({ status: status ?? undefined, stderr, value });
+ });
+ });
+}
+
+function buildIsolatedEnvironment() {
+ const keepNames = ['HOME', 'PATH', 'SystemRoot', 'TEMP', 'TMP', 'TMPDIR', 'USERPROFILE'];
+ const environment = { GPT_IMAGE_AGENT_LOAD_ENV_FILE: '0' };
+ for (const name of keepNames) {
+ if (process.env[name] !== undefined) environment[name] = process.env[name];
+ }
+ return environment;
+}
+
+async function readJsonBody(request) {
+ let text = '';
+ request.setEncoding('utf8');
+ for await (const chunk of request) text += chunk;
+ return text ? JSON.parse(text) : {};
+}
+
+function sendJson(response, status, body) {
+ response.writeHead(status, { 'content-type': 'application/json' });
+ response.end(JSON.stringify(body));
+}
+
+function escapeRegExp(value) {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/scripts/dependency-installation.mjs b/scripts/dependency-installation.mjs
index 77f98f54e0df02ae6acf6e6597dd64757658d6b0..035bface66de7a0282c1005ab8df02b1f58d4e26 100644
--- a/scripts/dependency-installation.mjs
+++ b/scripts/dependency-installation.mjs
@@ -79,13 +79,13 @@ function collectDirectDependencies(rootPackage) {
}
function collectRootLockMismatches(directDependencies, packages) {
- return directDependencies.filter((name) => !readPackageVersion(packages[`node_modules/${name}`]));
+ return directDependencies.filter((name) => !readPackageVersion(packages, `node_modules/${name}`));
}
function collectHiddenLockMismatches(directDependencies, rootPackages, hiddenPackages) {
return directDependencies.flatMap((name) => {
- const expected = readPackageVersion(rootPackages[`node_modules/${name}`]);
- const actual = readPackageVersion(hiddenPackages[`node_modules/${name}`]);
+ const expected = readPackageVersion(rootPackages, `node_modules/${name}`);
+ const actual = readPackageVersion(hiddenPackages, `node_modules/${name}`);
return actual === expected ? [] : [{ name, expected, actual }];
});
}
@@ -107,7 +107,7 @@ function inspectDirectPackageManifests(nodeModulesPath, directDependencies, root
continue;
}
if (manifest.name !== name) nameMismatches.push({ expected: name, actual: manifest.name });
- const expected = readPackageVersion(rootPackages[`node_modules/${name}`]);
+ const expected = readPackageVersion(rootPackages, `node_modules/${name}`);
if (manifest.version !== expected) versionMismatches.push({ name, expected, actual: manifest.version });
}
return { missingPackages, invalidPackages, nameMismatches, versionMismatches };
@@ -125,8 +125,13 @@ function readPackageManifest(path) {
}
}
-function readPackageVersion(manifest) {
- return typeof manifest?.version === 'string' && manifest.version.length > 0 ? manifest.version : undefined;
+function readPackageVersion(packages, packagePath, visited = new Set()) {
+ if (visited.has(packagePath)) return undefined;
+ visited.add(packagePath);
+ const manifest = packages[packagePath];
+ if (typeof manifest?.version === 'string' && manifest.version.length > 0) return manifest.version;
+ if (!manifest?.link || typeof manifest.resolved !== 'string') return undefined;
+ return readPackageVersion(packages, manifest.resolved, visited);
}
function buildFailure(reason, details = {}) {
diff --git a/scripts/dependency-installation.test.mjs b/scripts/dependency-installation.test.mjs
index ae210fcec777244abc989a3f26682f0ba5525a6c..c411f7e5209c3bfde56d6de6483af28bb3b199a4 100644
--- a/scripts/dependency-installation.test.mjs
+++ b/scripts/dependency-installation.test.mjs
@@ -19,10 +19,29 @@ const ROOT_LOCKFILE = {
}
};
-async function createFixture({ hiddenLockfile, manifests = {} } = {}) {
+const LOCAL_LINK_ROOT_LOCKFILE = {
+ name: 'fixture',
+ lockfileVersion: 3,
+ packages: {
+ '': {
+ name: 'fixture',
+ dependencies: { demo: 'file:vendor/demo' }
+ },
+ 'node_modules/demo': {
+ resolved: 'vendor/demo',
+ link: true
+ },
+ 'vendor/demo': {
+ name: 'demo',
+ version: '1.0.0'
+ }
+ }
+};
+
+async function createFixture({ rootLockfile = ROOT_LOCKFILE, hiddenLockfile, manifests = {} } = {}) {
const root = await mkdtemp(join(tmpdir(), 'gipc-dependency-installation-'));
await mkdir(join(root, 'node_modules'), { recursive: true });
- await writeFile(join(root, 'package-lock.json'), JSON.stringify(ROOT_LOCKFILE));
+ await writeFile(join(root, 'package-lock.json'), JSON.stringify(rootLockfile));
if (hiddenLockfile !== undefined) {
await writeFile(join(root, 'node_modules', '.package-lock.json'), JSON.stringify(hiddenLockfile));
}
@@ -105,4 +124,45 @@ describe('dependency installation inspection', () => {
await rm(root, { force: true, recursive: true });
}
});
+
+ it('accepts a local file dependency whose link target matches both lockfiles', async () => {
+ const root = await createFixture({
+ rootLockfile: LOCAL_LINK_ROOT_LOCKFILE,
+ hiddenLockfile: buildHiddenLockfile(LOCAL_LINK_ROOT_LOCKFILE.packages),
+ manifests: {
+ demo: { name: 'demo', version: '1.0.0' }
+ }
+ });
+ try {
+ const state = inspectDependencyInstallation(root);
+
+ assert.equal(state.ok, true);
+ assert.deepEqual(state.directDependencies, ['demo']);
+ } finally {
+ await rm(root, { force: true, recursive: true });
+ }
+ });
+
+ it('detects a local file dependency version mismatch in the hidden lockfile', async () => {
+ const hiddenLockfile = buildHiddenLockfile({
+ ...LOCAL_LINK_ROOT_LOCKFILE.packages,
+ 'vendor/demo': { name: 'demo', version: '1.1.0' }
+ });
+ const root = await createFixture({
+ rootLockfile: LOCAL_LINK_ROOT_LOCKFILE,
+ hiddenLockfile,
+ manifests: {
+ demo: { name: 'demo', version: '1.0.0' }
+ }
+ });
+ try {
+ const state = inspectDependencyInstallation(root);
+
+ assert.equal(state.ok, false);
+ assert.equal(state.reason, 'hidden_lockfile_package_mismatch');
+ assert.deepEqual(state.hiddenLockMismatches, [{ name: 'demo', expected: '1.0.0', actual: '1.1.0' }]);
+ } finally {
+ await rm(root, { force: true, recursive: true });
+ }
+ });
});
diff --git a/scripts/docker-build-context.test.mjs b/scripts/docker-build-context.test.mjs
index 8f635c434f0581993e64e16f7a705cd6f246ff65..fd1381e364df199ec0569d6fa2203a462da882e2 100644
--- a/scripts/docker-build-context.test.mjs
+++ b/scripts/docker-build-context.test.mjs
@@ -16,6 +16,7 @@ describe('Docker build context', () => {
assert.match(dockerignore, /^\.env\.\*$/m);
assert.match(dockerignore, /^!\.env\.real-smoke\.example$/m);
assert.match(dockerfile, /^COPY \. \.$/m);
+ assert.match(dockerfile, /^COPY vendor\/brace-expansion-compat \.\/vendor\/brace-expansion-compat$/m);
assert.match(gitignore, /^!\.env\.real-smoke\.example$/m);
assert.match(realSmokeTemplate, /^IMAGE_REAL_SMOKE_TIMEOUT_MS=240000$/m);
});
diff --git a/scripts/env-summary.mjs b/scripts/env-summary.mjs
index 42e02ad1ada0c461ea2c6854f464b706a80bb052..210a251d115e9453b3a5cc8b13b1f51dc838980f 100644
--- a/scripts/env-summary.mjs
+++ b/scripts/env-summary.mjs
@@ -6,6 +6,7 @@ import { isMainModule, printJson, runCommand } from './command-center-utils.mjs'
const DEFAULT_ENV_FILES = ['.env.local', '.env.real-smoke.local', '.env.agent.local'];
const SECRET_NAME_PATTERN = /(API_?KEY|API_?KEYS|TOKEN|PASSWORD|SECRET|CREDENTIAL|PRIVATE)/i;
+const PRIVATE_ENDPOINT_NAME_PATTERN = /PROXY/i;
const URL_NAME_PATTERN = /(BASE_URL|URL|ENDPOINT|HOST)$/i;
const DOCKER_INSPECT_TIMEOUT_MS = 10_000;
@@ -53,7 +54,7 @@ export function summarizeEnvEntries(entries) {
function summarizeEnvEntry(name, value) {
const set = value.length > 0;
- const sensitive = SECRET_NAME_PATTERN.test(name);
+ const sensitive = SECRET_NAME_PATTERN.test(name) || PRIVATE_ENDPOINT_NAME_PATTERN.test(name);
const summary = { name, set, sensitive, value_kind: classifyValue(value) };
if (sensitive && set) {
summary.item_count = value.split(',').map((item) => item.trim()).filter(Boolean).length;
diff --git a/scripts/env-summary.test.mjs b/scripts/env-summary.test.mjs
index 2c98c27f3964da86af44b2e9c92e7a36dc195e97..2b992627932d12fde1043d48742ece5a6b479b4c 100644
--- a/scripts/env-summary.test.mjs
+++ b/scripts/env-summary.test.mjs
@@ -17,6 +17,7 @@ describe('env-summary', () => {
'OPENAI_API_KEY=sk-real-looking-secret-value',
'OPENAI_CHANNEL_1_API_KEYS=key-one,key-two',
'OPENAI_API_BASE_URL=https://api.example.com/v1',
+ 'OPENAI_UPSTREAM_PROXY_URL=http://proxy.internal.example:8080',
'REDIS_HOST=localhost:6379',
'PUBLIC_NAME=value # deployment note',
'ENABLE_STREAMING_BATCH=true',
@@ -30,6 +31,8 @@ describe('env-summary', () => {
assert.equal(serialized.includes('sk-real-looking-secret-value'), false);
assert.equal(serialized.includes('key-one'), false);
assert.equal(serialized.includes('key-two'), false);
+ assert.equal(serialized.includes('proxy.internal.example'), false);
+ assert.equal(serialized.includes('8080'), false);
assert.equal(serialized.includes('deployment note'), false);
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_API_KEY'), {
name: 'OPENAI_API_KEY',
@@ -39,6 +42,13 @@ describe('env-summary', () => {
item_count: 1
});
assert.equal(summary.find((item) => item.name === 'OPENAI_CHANNEL_1_API_KEYS')?.item_count, 2);
+ assert.deepEqual(summary.find((item) => item.name === 'OPENAI_UPSTREAM_PROXY_URL'), {
+ name: 'OPENAI_UPSTREAM_PROXY_URL',
+ set: true,
+ sensitive: true,
+ value_kind: 'url',
+ item_count: 1
+ });
assert.deepEqual(summary.find((item) => item.name === 'OPENAI_API_BASE_URL')?.url, {
valid: true,
protocol: 'https',
diff --git a/scripts/smoke-image-upstream-compat.mjs b/scripts/smoke-image-upstream-compat.mjs
index df340b4b239be00ff97bfe9ff82a85ffba42bed6..62bfe61131c4fd456d42f34cb4c6ddc13095dd63 100644
--- a/scripts/smoke-image-upstream-compat.mjs
+++ b/scripts/smoke-image-upstream-compat.mjs
@@ -15,6 +15,7 @@ function configureRouteEnv() {
'APP_PASSWORD',
'OPENAI_API_KEY',
'OPENAI_API_BASE_URL',
+ 'OPENAI_UPSTREAM_PROXY_URL',
'OPENAI_CHANNEL_1_API_KEYS',
'OPENAI_CHANNEL_1_BASE_URL',
'IMAGE_GENERATION_BACKEND',
diff --git a/scripts/smoke-image-upstream-local-final-gate.mjs b/scripts/smoke-image-upstream-local-final-gate.mjs
index 7ef839d5c1479e711f39b8c592f6d93818594ddd..b6beb4195db03e9c1570e82ee5055dcde69ee71d 100644
--- a/scripts/smoke-image-upstream-local-final-gate.mjs
+++ b/scripts/smoke-image-upstream-local-final-gate.mjs
@@ -187,6 +187,7 @@ function isSmokeEnvKey(key) {
key.startsWith('OPENAI_CHANNEL_') ||
key === 'OPENAI_API_BASE_URL' ||
key === 'OPENAI_API_KEY' ||
+ key === 'OPENAI_UPSTREAM_PROXY_URL' ||
key === 'OPENAI_RESPONSES_API_MODEL' ||
key === 'OPENAI_ROUTING_STRATEGY' ||
key === 'OPENAI_CHANNELS_JSON' ||
diff --git a/scripts/smoke-image-upstream-real.test.mjs b/scripts/smoke-image-upstream-real.test.mjs
index fdf00585ded8fa942c55fd955c86f3d2915185c2..8694ce900f8ca4fee506892ce6c95ee0c992410f 100644
--- a/scripts/smoke-image-upstream-real.test.mjs
+++ b/scripts/smoke-image-upstream-real.test.mjs
@@ -1025,6 +1025,7 @@ function isSmokeEnvKey(key) {
key.startsWith('OPENAI_CHANNEL_') ||
key === 'OPENAI_API_BASE_URL' ||
key === 'OPENAI_API_KEY' ||
+ key === 'OPENAI_UPSTREAM_PROXY_URL' ||
key === 'OPENAI_RESPONSES_API_MODEL' ||
key === 'OPENAI_ROUTING_STRATEGY' ||
key === 'OPENAI_CHANNELS_JSON' ||
diff --git a/skills/gpt-image-playground-agent/SKILL.md b/skills/gpt-image-playground-agent/SKILL.md
index dcce1193ae6e446a52dc736108d44c347aa53f7f..b3504f7dc22fe9bed334f04b516106dcd5244106 100644
--- a/skills/gpt-image-playground-agent/SKILL.md
+++ b/skills/gpt-image-playground-agent/SKILL.md
@@ -1,6 +1,6 @@
---
name: gpt-image-playground-agent
-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,而不是临时编写 API 调用脚本。
+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 调用脚本。
---
# GPT Image Playground Agent
@@ -18,6 +18,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
- 查询页面请求的结果反馈或日志诊断摘要:优先运行 `scripts/diagnose-request.mjs`。
- 查询当前实例内存中的渠道、凭证和请求方式健康状态:优先运行 `scripts/diagnose-channel-health.mjs`。它只读调用 Agent API,不触发上游探测或图片生成,也不能证明真实上游可用。
- 诊断上游图片接口:优先运行 `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`。
+- 对新上游完成固定四模式验证并准备可直接使用的私有配置:运行 `scripts/channel-capability-matrix.mjs`。只有用户明确允许计费时才传 `--allow-billable`;需要输出配置时再显式传 `--write-env-file `。它固定串行验证 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`,不会重启服务或部署。
- 不要临时编写 Node/Python/shell 脚本、curl 命令或手写 fetch/FormData 来重复实现这些脚本已经覆盖的 API 调用。
- 只有在内置脚本缺少用户明确需要的能力时,才修改或扩展 `scripts/` 内的预置脚本,并同步补测试;不要在仓库外留下 ad hoc 调用脚本。
- 先用 dry-run、`--check-remote` 或 `--contract-check` 检查请求、路由、鉴权和服务声明的默认编排入口;只有用户明确允许真实计费时才加 `--allow-billable`。
diff --git a/skills/gpt-image-playground-agent/agents/openai.yaml b/skills/gpt-image-playground-agent/agents/openai.yaml
index a0b6686ab63992c0b3d00286aacf05ad981d5ea3..c42120c32015d3e7443b30a4037d7561ad022402 100644
--- a/skills/gpt-image-playground-agent/agents/openai.yaml
+++ b/skills/gpt-image-playground-agent/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
display_name: "GPT Image Agent API"
- short_description: "使用内置脚本调用图片 Agent API,并确认目标服务地址"
- default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;运行前先定位服务地址,用户提供 URL 时显式传 --base-url,自动发现本地或环境变量地址时先确认,不要临时编写 API 调用脚本。"
+ short_description: "用内置脚本生成图片、诊断服务并验证上游渠道能力矩阵"
+ default_prompt: "使用 $gpt-image-playground-agent 先选择并运行内置脚本;新上游先运行能力矩阵,只有真实验证通过后才生成私有渠道配置,不要临时编写 API 调用脚本。"
diff --git a/skills/gpt-image-playground-agent/references/api.md b/skills/gpt-image-playground-agent/references/api.md
index 84267de6e7cd9f5f3a906a430c85db4aebc2b861..ba0d08ca2c58844a25d92996e0a6f93931779ed9 100644
--- a/skills/gpt-image-playground-agent/references/api.md
+++ b/skills/gpt-image-playground-agent/references/api.md
@@ -29,6 +29,7 @@ Agent API 是给自动化客户端使用的机器接口,不是自治 Agent 平
- `scripts/diagnose-request.mjs`:按页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断,支持 `--base-url` 固定目标服务。
- `scripts/diagnose-channel-health.mjs`:通过 capabilities 声明的 Agent 端点读取当前服务进程的渠道健康快照,支持 `--base-url` 和 `--output`。
- `scripts/probe-upstream-image.mjs`:上游图片接口连通性探针。
+- `scripts/channel-capability-matrix.mjs`:固定串行验证四种上游图片请求方式,并在真实验证通过后生成私有渠道 env 配置。
生成、编辑和批量脚本默认只做 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。
上游探针默认只检查 DNS、TLS 和 `/models`,必须显式添加 `--allow-billable` 才会调用上游 `/images/generations`。
@@ -192,6 +193,16 @@ node "/scripts/batch-images.mjs" --allow-billable --input tasks.json
上游探针读取 `GPT_IMAGE_UPSTREAM_BASE_URL` 或 `OPENAI_API_BASE_URL` 作为上游地址,读取 `GPT_IMAGE_UPSTREAM_API_KEY` 或 `OPENAI_API_KEY` 作为上游鉴权。base URL 必须是无凭据、无查询参数和无片段的 `http`/`https` 绝对 URL。输出不会包含 key,也不会输出完整 base64。
+## 渠道能力矩阵和私有配置
+
+```text
+node "/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
+```
+
+该脚本固定串行调用 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`,不会把未测、失败、pending/poll 或远程 URL-only 结果写入渠道白名单。`--write-env-file` 必须与 `--allow-billable` 一起使用;写入还要求 `/models` 成功、四种模式都有报告、至少一个模式返回本服务可消费的最终图片、API Key 有效,且 Responses 模式有可用顶层模型。任何条件不满足时只输出脱敏矩阵报告,不创建目标文件。
+
+写入的独立私有 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`,不会重启服务或部署。
+
## 能力查询
```http
@@ -207,6 +218,7 @@ GET /api/agent/capabilities
- `image_transport.upstream_timeout_ms`:当前服务端图片上游请求超时,脚本未显式传 `--timeout-ms` 时会用它延长默认超时。
- `image_transport.stream_data_interval_timeout_ms`:已建立图片流的单次数据空闲超时;`0` 表示服务端禁用该空闲计时器。
- `image_transport.upstream_max_retries`:OpenAI SDK 图片请求自动重试次数;默认 `0`,避免长耗时图片请求被 SDK 自动重试后重复计费。
+- `image_transport.upstream_proxy`:全局服务端上游代理摘要,只包含 `configured` 和可选的 `protocol`(`http` 或 `https`);不会返回代理主机、端口、认证信息或完整 URL。代理由部署管理员通过 `OPENAI_UPSTREAM_PROXY_URL` 配置,只影响服务端到图片上游的出站连接。
- `model_limits.gpt-image-2.max_edge`:最大单边像素,当前为 `3840`。
- `model_limits.gpt-image-2.max_pixels`:最大总像素,当前为 `8294400`。
- `model_limits.gpt-image-2.edge_multiple`:宽高必须是该值的倍数,当前为 `16`。
@@ -233,6 +245,7 @@ GET /api/agent/capabilities
- `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要,包含该渠道有效 `request_modes` 和按白名单过滤后的 `request_mode_priority`。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
+- `upstream_request_headers.channels[].upstream_proxy`:该渠道的有效上游代理摘要。`OPENAI_CHANNEL_N_PROXY_URL` 优先于 `OPENAI_UPSTREAM_PROXY_URL`;摘要只返回 `configured` 和 `protocol`,不返回代理地址或端口。
- `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`。
- `providerManifests[].manifest.executionSupport`:`implemented` 表示当前执行器可按现有 Images/Responses 路径执行;`declared_only` 表示 manifest 声明了 async-poll,但当前执行器不会自动轮询 provider `poll` 配置。pending/poll_url 只能作为诊断线索,不是可写入 request mode 白名单的通过证明。
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
@@ -273,6 +286,8 @@ GET /api/agent/capabilities
上游请求头策略由服务端统一执行。默认 `User-Agent` 是 `gpt-image-playground/`;可用 `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 覆盖;固定业务头和鉴权头始终由调用路径设置。
+上游代理同样由服务端统一执行:`OPENAI_UPSTREAM_PROXY_URL` 为全局默认值,`OPENAI_CHANNEL_N_PROXY_URL` 可覆盖单个渠道。它们只接受无认证、无路径、无查询参数和无片段的 `http://` 或 `https://` 根代理地址,不支持 SOCKS;配置变更需重启或重新部署服务。代理适用于服务端上游 API、SSE、同源结果图下载、渠道恢复探测和 new-api 用量日志,不影响 Agent 客户端到 Playground 的连接。
+
## Job Polling
```http
diff --git a/skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs b/skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..13fdc76f46a9b9d2919b6f05e2529630e11c019e
--- /dev/null
+++ b/skills/gpt-image-playground-agent/scripts/channel-capability-matrix.mjs
@@ -0,0 +1,367 @@
+#!/usr/bin/env node
+import { spawn } from 'node:child_process';
+import { closeSync, chmodSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, renameSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
+import { randomBytes } from 'node:crypto';
+import { basename, dirname, join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ buildCapabilityMatrix,
+ buildChannelEnvConfig,
+ buildRedactedChannelEnvPreview,
+ createDefaultChannelId,
+ redactKnownSecrets,
+ resolveUpstreamApiKey,
+ validateChannelApiKey,
+ validateChannelId
+} from './lib/channel-capability-matrix.mjs';
+import {
+ errorMessage,
+ loadPrivateAgentEnvFile,
+ normalizeBaseUrl,
+ readConfiguredPositiveInteger,
+ readOptionValue
+} from './lib/script-utils.mjs';
+
+const SCRIPT_DIRECTORY = dirname(fileURLToPath(import.meta.url));
+const PROBE_SCRIPT_PATH = join(SCRIPT_DIRECTORY, 'probe-upstream-image.mjs');
+const DEFAULT_UPSTREAM_BASE_URL = 'https://api.openai.com/v1';
+
+loadPrivateAgentEnvFile();
+
+let options;
+try {
+ options = parseArgs(process.argv.slice(2));
+ if (options.help) {
+ printUsage();
+ process.exit(0);
+ }
+ validateOptions(options);
+} catch (error) {
+ console.error(errorMessage(error));
+ printUsage();
+ process.exit(2);
+}
+
+try {
+ const baseUrl = normalizeBaseUrl(
+ options.baseUrl || process.env.GPT_IMAGE_UPSTREAM_BASE_URL || process.env.OPENAI_API_BASE_URL || DEFAULT_UPSTREAM_BASE_URL
+ );
+ const upstream = new URL(baseUrl);
+ const apiKey = resolveUpstreamApiKey();
+ const apiKeyValidation = validateChannelApiKey(apiKey.value);
+ const channelId = options.channelId || createDefaultChannelId(upstream.hostname, options.channelIndex);
+ const channelIdValidation = validateChannelId(channelId);
+ if (!channelIdValidation.ok) throw new Error('--channel-id 只能包含字母、数字、点、下划线和连字符,且长度不超过 64。');
+ if (options.writeEnvFile && !apiKeyValidation.ok) {
+ throw new Error('生成私有配置需要设置有效的 GPT_IMAGE_UPSTREAM_API_KEY 或 OPENAI_API_KEY。');
+ }
+ if (options.writeEnvFile) {
+ const target = inspectPrivateEnvTarget(options.writeEnvFile, options.overwrite);
+ if (!target.ok) throw new Error(target.message);
+ }
+
+ await runCapabilityMatrix({ baseUrl, upstream, apiKey, apiKeyValidation, channelId });
+} catch (error) {
+ console.error(errorMessage(error));
+ process.exit(2);
+}
+
+function parseArgs(argv) {
+ const parsed = {
+ baseUrl: undefined,
+ model: 'gpt-image-2',
+ responsesModel: undefined,
+ prompt: 'channel capability matrix probe',
+ size: '1024x1024',
+ quality: 'low',
+ format: 'webp',
+ outputCompression: undefined,
+ timeoutMs: undefined,
+ channelIndex: 1,
+ channelId: undefined,
+ writeEnvFile: undefined,
+ overwrite: false,
+ allowBillable: false,
+ help: false
+ };
+
+ for (let index = 0; index < argv.length; index += 1) {
+ const arg = argv[index];
+ if (arg === '--base-url') parsed.baseUrl = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--model') parsed.model = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--responses-model') parsed.responsesModel = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--prompt') parsed.prompt = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--size') parsed.size = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--quality') parsed.quality = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--format' || arg === '--output-format') parsed.format = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--output-compression') parsed.outputCompression = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--timeout-ms') parsed.timeoutMs = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--channel-index') parsed.channelIndex = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--channel-id') parsed.channelId = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--write-env-file') parsed.writeEnvFile = readOptionValue(argv, (index += 1), arg);
+ else if (arg === '--overwrite') parsed.overwrite = true;
+ else if (arg === '--allow-billable') parsed.allowBillable = true;
+ else if (arg === '--help' || arg === '-h') parsed.help = true;
+ else throw new Error('包含未知参数。');
+ }
+
+ return parsed;
+}
+
+function validateOptions(parsed) {
+ if (!/^[1-9]\d*$/.test(String(parsed.channelIndex))) {
+ throw new Error('--channel-index 必须是正整数。');
+ }
+ parsed.channelIndex = Number(parsed.channelIndex);
+ if (!Number.isSafeInteger(parsed.channelIndex)) throw new Error('--channel-index 必须是正整数。');
+ if (parsed.overwrite && !parsed.writeEnvFile) throw new Error('--overwrite 必须和 --write-env-file 一起使用。');
+ if (parsed.writeEnvFile && !parsed.allowBillable) {
+ throw new Error('--write-env-file 需要同时使用 --allow-billable。');
+ }
+ if (parsed.timeoutMs !== undefined) readConfiguredPositiveInteger(parsed.timeoutMs, '--timeout-ms', 30000);
+}
+
+async function runCapabilityMatrix(input) {
+ const probeResult = await runProbe(options, input.baseUrl);
+ const probeReport = parseProbeReport(probeResult.stdout);
+ const redactText = (value) => redactKnownSecrets(value, [input.apiKey.value]);
+ const matrix = buildCapabilityMatrix({
+ probeReport,
+ allowBillable: options.allowBillable,
+ apiKeyValid: input.apiKeyValidation.ok,
+ apiKeyError: input.apiKeyValidation.reason,
+ redactText
+ });
+ const configuration = {
+ ...matrix.configuration,
+ channel_index: options.channelIndex,
+ channel_id: input.channelId,
+ ...(matrix.configuration.ready
+ ? {
+ env_preview: buildRedactedChannelEnvPreview({
+ channelIndex: options.channelIndex,
+ channelId: input.channelId,
+ baseUrl: input.baseUrl,
+ requestModes: matrix.configuration.request_modes,
+ requestModePriority: matrix.configuration.request_mode_priority,
+ responsesModel: matrix.configuration.responses_model
+ })
+ }
+ : {})
+ };
+
+ let write = { requested: Boolean(options.writeEnvFile), attempted: false, written: false, reason: 'not_requested' };
+ if (options.writeEnvFile) {
+ if (!configuration.ready) {
+ write = { requested: true, attempted: false, written: false, reason: 'configuration_not_ready' };
+ } else {
+ const content = buildChannelEnvConfig({
+ channelIndex: options.channelIndex,
+ channelId: input.channelId,
+ baseUrl: input.baseUrl,
+ apiKey: input.apiKey.value,
+ requestModes: configuration.request_modes,
+ requestModePriority: configuration.request_mode_priority,
+ responsesModel: configuration.responses_model
+ });
+ write = { requested: true, attempted: true, ...writePrivateEnvFile(options.writeEnvFile, content, options.overwrite) };
+ }
+ }
+
+ const report = {
+ ok: configuration.ready && (!options.writeEnvFile || write.written),
+ billable: options.allowBillable,
+ transport: 'channel_capability_matrix',
+ upstream: {
+ base_url: input.baseUrl,
+ host: input.upstream.host,
+ api_key_configured: Boolean(input.apiKey.value)
+ },
+ probe: {
+ completed: probeReport !== undefined,
+ exit_code: probeResult.exitCode,
+ stderr_present: probeResult.stderr.trim().length > 0
+ },
+ preflight: matrix.preflight,
+ matrix: {
+ requested: matrix.requested,
+ coverage_complete: matrix.coverage_complete,
+ fully_supported: matrix.fully_supported,
+ passed: matrix.passed,
+ failed: matrix.failed,
+ skipped: matrix.skipped,
+ modes: matrix.modes
+ },
+ configuration,
+ write,
+ summary: {
+ ok: configuration.ready && (!options.writeEnvFile || write.written),
+ billable: options.allowBillable,
+ request_modes: configuration.request_modes,
+ blocking_reasons: configuration.blocking_reasons,
+ next_action: readNextAction({ configuration, write, writeRequested: Boolean(options.writeEnvFile) })
+ }
+ };
+
+ console.log(JSON.stringify(redactKnownSecrets(report, [input.apiKey.value]), null, 2));
+ process.exitCode = report.ok ? 0 : 1;
+}
+
+async function runProbe(parsed, normalizedBaseUrl) {
+ const args = [
+ PROBE_SCRIPT_PATH,
+ '--base-url',
+ normalizedBaseUrl,
+ '--model',
+ parsed.model,
+ '--prompt',
+ parsed.prompt,
+ '--size',
+ parsed.size,
+ '--quality',
+ parsed.quality,
+ '--format',
+ parsed.format,
+ '--request-mode',
+ 'all'
+ ];
+ if (parsed.responsesModel) args.push('--responses-model', parsed.responsesModel);
+ if (parsed.outputCompression !== undefined) args.push('--output-compression', parsed.outputCompression);
+ if (parsed.timeoutMs !== undefined) args.push('--timeout-ms', parsed.timeoutMs);
+ if (parsed.allowBillable) args.push('--allow-billable');
+
+ return await new Promise((resolveResult) => {
+ const child = spawn(process.execPath, args, {
+ env: process.env,
+ stdio: ['ignore', 'pipe', 'pipe']
+ });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.setEncoding('utf8');
+ child.stderr.setEncoding('utf8');
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk;
+ });
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk;
+ });
+ child.once('error', () => {
+ resolveResult({ exitCode: undefined, stdout, stderr });
+ });
+ child.once('close', (exitCode) => {
+ resolveResult({ exitCode: exitCode ?? undefined, stdout, stderr });
+ });
+ });
+}
+
+function parseProbeReport(stdout) {
+ try {
+ const parsed = JSON.parse(stdout);
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+function inspectPrivateEnvTarget(targetPath, overwrite) {
+ const absoluteTarget = resolve(targetPath);
+ const targetDirectory = dirname(absoluteTarget);
+ const targetName = basename(absoluteTarget);
+ if (!targetName || targetName === '.') {
+ return { ok: false, message: '--write-env-file 必须指定常规文件路径。' };
+ }
+
+ try {
+ const directoryStat = statSync(targetDirectory);
+ if (!directoryStat.isDirectory()) {
+ return { ok: false, message: '--write-env-file 的父目录不可用。' };
+ }
+ } catch {
+ return { ok: false, message: '--write-env-file 的父目录不可用。' };
+ }
+
+ if (!existsSync(absoluteTarget)) return { ok: true };
+ try {
+ const targetStat = lstatSync(absoluteTarget);
+ if (targetStat.isSymbolicLink()) {
+ return { ok: false, message: '--write-env-file 不接受符号链接目标。' };
+ }
+ if (!targetStat.isFile()) {
+ return { ok: false, message: '--write-env-file 只能覆盖常规文件。' };
+ }
+ if (!overwrite) {
+ return { ok: false, message: '目标私有配置文件已存在;确认替换后显式添加 --overwrite。' };
+ }
+ return { ok: true };
+ } catch {
+ return { ok: false, message: '--write-env-file 目标不可用。' };
+ }
+}
+
+function writePrivateEnvFile(targetPath, content, overwrite) {
+ const absoluteTarget = resolve(targetPath);
+ const targetDirectory = dirname(absoluteTarget);
+ const targetName = basename(absoluteTarget);
+ let temporaryPath;
+
+ try {
+ if (!targetName || targetName === '.') return { written: false, reason: 'invalid_target' };
+ const directoryStat = statSync(targetDirectory);
+ if (!directoryStat.isDirectory()) return { written: false, reason: 'invalid_target_directory' };
+
+ if (existsSync(absoluteTarget)) {
+ const targetStat = lstatSync(absoluteTarget);
+ if (targetStat.isSymbolicLink()) return { written: false, reason: 'target_is_symlink' };
+ if (!targetStat.isFile()) return { written: false, reason: 'target_not_regular_file' };
+ if (!overwrite) return { written: false, reason: 'target_exists' };
+ }
+
+ temporaryPath = join(targetDirectory, `.${targetName}.channel-capability-${process.pid}-${randomBytes(8).toString('hex')}.tmp`);
+ writeFileSync(temporaryPath, content, { encoding: 'utf8', flag: 'wx', mode: 0o600 });
+ chmodSync(temporaryPath, 0o600);
+ const descriptor = openSync(temporaryPath, 'r');
+ try {
+ fsyncSync(descriptor);
+ } finally {
+ closeSync(descriptor);
+ }
+
+ if (overwrite) {
+ renameSync(temporaryPath, absoluteTarget);
+ } else {
+ try {
+ linkSync(temporaryPath, absoluteTarget);
+ } catch (error) {
+ if (error && typeof error === 'object' && error.code === 'EEXIST') {
+ return { written: false, reason: 'target_exists' };
+ }
+ throw error;
+ }
+ unlinkSync(temporaryPath);
+ }
+ chmodSync(absoluteTarget, 0o600);
+ temporaryPath = undefined;
+ return { written: true, reason: 'written' };
+ } catch {
+ return { written: false, reason: 'write_failed' };
+ } finally {
+ if (temporaryPath && existsSync(temporaryPath)) unlinkSync(temporaryPath);
+ }
+}
+
+function readNextAction(input) {
+ if (!input.configuration.ready) return 'inspect_capability_matrix';
+ if (input.writeRequested && !input.write.written) return 'resolve_private_config_write';
+ if (input.writeRequested) return 'apply_private_config_and_restart_explicitly';
+ return 'write_private_config_explicitly';
+}
+
+function printUsage() {
+ console.error('用法:channel-capability-matrix.mjs [options]');
+ console.error('固定串行验证 Images/Responses 的 JSON 与 SSE 四种请求方式。');
+ console.error('只有 --allow-billable 且至少一个方式返回可消费图片时,才允许写入私有渠道配置。');
+ console.error(
+ '常用参数:--base-url --model --responses-model --prompt --size --quality --format --output-compression --timeout-ms --channel-index --channel-id --allow-billable --write-env-file --overwrite'
+ );
+ console.error('不会自动写入 .env.local、重启服务或部署。API Key 仅从 GPT_IMAGE_UPSTREAM_API_KEY 或 OPENAI_API_KEY 读取。');
+}
diff --git a/skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs b/skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..f793f67840d34787cf52db7b6f9003d63faf8518
--- /dev/null
+++ b/skills/gpt-image-playground-agent/scripts/lib/channel-capability-matrix.mjs
@@ -0,0 +1,323 @@
+export const CHANNEL_CAPABILITY_REQUEST_MODES = Object.freeze([
+ 'images-non-stream',
+ 'images-sse',
+ 'responses-non-stream',
+ 'responses-sse'
+]);
+
+export const DEFAULT_CHANNEL_CAPABILITY_REQUEST_MODE_PRIORITY = Object.freeze([
+ 'images-non-stream',
+ 'images-sse',
+ 'responses-non-stream',
+ 'responses-sse'
+]);
+
+const CONTROL_CHARACTER_PATTERN = /[\u0000\r\n]/;
+const SAFE_UNQUOTED_ENV_VALUE_PATTERN = /^[A-Za-z0-9._/:,@%+=-]+$/;
+
+export function resolveUpstreamApiKey(env = process.env) {
+ const preferred = readNonEmptyString(env.GPT_IMAGE_UPSTREAM_API_KEY);
+ if (preferred) return { value: preferred, source: 'GPT_IMAGE_UPSTREAM_API_KEY' };
+
+ const fallback = readNonEmptyString(env.OPENAI_API_KEY);
+ if (fallback) return { value: fallback, source: 'OPENAI_API_KEY' };
+
+ return { value: '', source: undefined };
+}
+
+export function validateChannelApiKey(value) {
+ if (!readNonEmptyString(value)) return { ok: false, reason: 'missing_api_key' };
+ if (CONTROL_CHARACTER_PATTERN.test(value)) return { ok: false, reason: 'invalid_api_key_characters' };
+ if (value.includes(',')) return { ok: false, reason: 'api_key_contains_comma' };
+ return { ok: true };
+}
+
+export function validateChannelId(value) {
+ const normalized = readNonEmptyString(value);
+ if (!normalized) return { ok: false, reason: 'missing_channel_id' };
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(normalized)) {
+ return { ok: false, reason: 'invalid_channel_id' };
+ }
+ return { ok: true, value: normalized };
+}
+
+export function createDefaultChannelId(hostname, channelIndex) {
+ const hostPart = String(hostname || 'upstream')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 48);
+ return `channel-${channelIndex}-${hostPart || 'upstream'}`;
+}
+
+export function buildCapabilityMatrix(input) {
+ const report = isRecord(input.probeReport) ? input.probeReport : {};
+ const requestModes = isRecord(report.request_modes) ? report.request_modes : {};
+ const modeReports = isRecord(requestModes.modes) ? requestModes.modes : {};
+ const requested = Array.isArray(requestModes.requested) ? requestModes.requested : [];
+ const modes = {};
+ const passed = [];
+ const failed = [];
+ const skipped = [];
+
+ for (const requestMode of CHANNEL_CAPABILITY_REQUEST_MODES) {
+ const mode = isRecord(modeReports[requestMode]) ? modeReports[requestMode] : undefined;
+ const summary = summarizeMode(requestMode, mode, input.redactText);
+ modes[requestMode] = summary;
+ if (summary.status === 'passed') passed.push(requestMode);
+ else if (summary.status === 'skipped') skipped.push(requestMode);
+ else failed.push(requestMode);
+ }
+
+ const models = summarizePreflight(report.models, input.redactText);
+ const coverageComplete =
+ CHANNEL_CAPABILITY_REQUEST_MODES.every((requestMode) => requested.includes(requestMode)) &&
+ CHANNEL_CAPABILITY_REQUEST_MODES.every((requestMode) => isRecord(modeReports[requestMode]));
+ const responsesModes = passed.filter((requestMode) => requestMode.startsWith('responses-'));
+ const responsesModel = readResponsesModel(modeReports, responsesModes);
+ const imageBackend = resolveDefaultImageBackend(passed);
+ const blockingReasons = [];
+
+ if (!input.allowBillable) blockingReasons.push('billable_verification_required');
+ if (!models.ok) blockingReasons.push('models_preflight_failed');
+ if (!coverageComplete) blockingReasons.push('incomplete_request_mode_matrix');
+ if (!input.apiKeyValid) blockingReasons.push(input.apiKeyError || 'missing_api_key');
+ if (passed.length === 0) blockingReasons.push('no_consumable_image_mode');
+ if (responsesModes.length > 0 && !responsesModel) blockingReasons.push('missing_responses_model');
+
+ return {
+ requested: [...CHANNEL_CAPABILITY_REQUEST_MODES],
+ coverage_complete: coverageComplete,
+ fully_supported: passed.length === CHANNEL_CAPABILITY_REQUEST_MODES.length,
+ passed,
+ failed,
+ skipped,
+ modes,
+ preflight: {
+ dns: summarizePreflight(report.dns, input.redactText),
+ tls: summarizePreflight(report.tls, input.redactText),
+ models
+ },
+ configuration: {
+ ready: blockingReasons.length === 0,
+ blocking_reasons: blockingReasons,
+ request_modes: passed,
+ request_mode_priority: orderRequestModesByDefaultPriority(passed),
+ ...(imageBackend ? { image_backend: imageBackend, streaming_strategy: 'auto' } : {}),
+ responses_backend_required: responsesModes.length > 0,
+ responses_model: responsesModel || undefined
+ }
+ };
+}
+
+export function buildChannelEnvConfig(input) {
+ const channelIndex = readPositiveChannelIndex(input.channelIndex);
+ const channelId = readRequiredEnvValue(input.channelId, 'channel_id');
+ const baseUrl = readRequiredEnvValue(input.baseUrl, 'base_url');
+ const apiKey = readRequiredEnvValue(input.apiKey, 'api_key');
+ const requestModes = normalizeRequestModes(input.requestModes);
+ const requestModePriority = normalizeRequestModes(input.requestModePriority || requestModes);
+ const responsesModel = readNonEmptyString(input.responsesModel);
+ const hasResponsesMode = requestModes.some((requestMode) => requestMode.startsWith('responses-'));
+ const imageBackend = resolveDefaultImageBackend(requestModes);
+ const plainHttpAllowlistValue = resolvePlainHttpAllowlistValue(baseUrl);
+
+ if (hasResponsesMode && !responsesModel) {
+ throw new Error('missing_responses_model');
+ }
+ if (!imageBackend) throw new Error('missing_image_backend');
+
+ const prefix = `OPENAI_CHANNEL_${channelIndex}`;
+ const lines = [
+ '# Generated after a billable upstream capability matrix probe.',
+ '# This file contains credentials. Keep it private and do not commit it.',
+ `${prefix}_ID=${serializeEnvValue(channelId)}`,
+ `${prefix}_BASE_URL=${serializeEnvValue(baseUrl)}`,
+ `${prefix}_API_KEYS=${serializeEnvValue(apiKey)}`,
+ `${prefix}_REQUEST_MODES=${serializeEnvValue(requestModes.join(','))}`,
+ `${prefix}_REQUEST_MODE_PRIORITY=${serializeEnvValue(requestModePriority.join(','))}`,
+ `IMAGE_GENERATION_BACKEND=${serializeEnvValue(imageBackend)}`,
+ 'IMAGE_STREAMING_STRATEGY=auto'
+ ];
+
+ if (plainHttpAllowlistValue) {
+ lines.push(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${serializeEnvValue(plainHttpAllowlistValue)}`);
+ }
+
+ if (hasResponsesMode) {
+ lines.push('ENABLE_RESPONSES_IMAGE_BACKEND=true');
+ lines.push(`OPENAI_RESPONSES_API_MODEL=${serializeEnvValue(responsesModel)}`);
+ }
+
+ return `${lines.join('\n')}\n`;
+}
+
+export function buildRedactedChannelEnvPreview(input) {
+ const channelIndex = readPositiveChannelIndex(input.channelIndex);
+ const requestModes = normalizeRequestModes(input.requestModes);
+ const requestModePriority = normalizeRequestModes(input.requestModePriority || requestModes);
+ const imageBackend = resolveDefaultImageBackend(requestModes);
+ const baseUrl = readRequiredEnvValue(input.baseUrl, 'base_url');
+ const plainHttpAllowlistValue = resolvePlainHttpAllowlistValue(baseUrl);
+ if (!imageBackend) throw new Error('missing_image_backend');
+ const prefix = `OPENAI_CHANNEL_${channelIndex}`;
+ const lines = [
+ `${prefix}_ID=${serializeEnvValue(readRequiredEnvValue(input.channelId, 'channel_id'))}`,
+ `${prefix}_BASE_URL=${serializeEnvValue(baseUrl)}`,
+ `${prefix}_API_KEYS=[redacted]`,
+ `${prefix}_REQUEST_MODES=${serializeEnvValue(requestModes.join(','))}`,
+ `${prefix}_REQUEST_MODE_PRIORITY=${serializeEnvValue(requestModePriority.join(','))}`,
+ `IMAGE_GENERATION_BACKEND=${serializeEnvValue(imageBackend)}`,
+ 'IMAGE_STREAMING_STRATEGY=auto'
+ ];
+
+ if (plainHttpAllowlistValue) {
+ lines.push(`OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS=${serializeEnvValue(plainHttpAllowlistValue)}`);
+ }
+
+ if (requestModes.some((requestMode) => requestMode.startsWith('responses-'))) {
+ lines.push('ENABLE_RESPONSES_IMAGE_BACKEND=true');
+ lines.push(`OPENAI_RESPONSES_API_MODEL=${serializeEnvValue(readRequiredEnvValue(input.responsesModel, 'responses_model'))}`);
+ }
+
+ return lines;
+}
+
+export function redactKnownSecrets(value, secrets) {
+ const normalizedSecrets = Array.from(new Set(secrets.map(readNonEmptyString).filter(Boolean)));
+ if (typeof value === 'string') {
+ return normalizedSecrets.reduce((result, secret) => result.split(secret).join('[redacted]'), value);
+ }
+ if (Array.isArray(value)) return value.map((item) => redactKnownSecrets(item, normalizedSecrets));
+ if (!isRecord(value)) return value;
+ return Object.fromEntries(
+ Object.entries(value).map(([key, nestedValue]) => [key, redactKnownSecrets(nestedValue, normalizedSecrets)])
+ );
+}
+
+function summarizeMode(requestMode, mode, redactText) {
+ if (!mode) {
+ return {
+ request_mode: requestMode,
+ status: 'failed',
+ ok: false,
+ billable: false,
+ reason: 'not_reported'
+ };
+ }
+
+ const skipped = mode.skipped === true;
+ const passed = mode.ok === true && !skipped && mode.billable === true;
+ const status = skipped ? 'skipped' : passed ? 'passed' : 'failed';
+ const result = {
+ request_mode: requestMode,
+ status,
+ ok: mode.ok === true,
+ billable: mode.billable === true,
+ ...(skipped ? { skipped: true } : {}),
+ ...(readSafeInteger(mode.status) !== undefined ? { upstream_status: mode.status } : {}),
+ ...(readSafeInteger(mode.elapsed_ms) !== undefined ? { elapsed_ms: mode.elapsed_ms } : {}),
+ ...(readNonEmptyString(mode.category) ? { category: redact(mode.category, redactText) } : {}),
+ ...(readNonEmptyString(mode.reason) ? { reason: redact(mode.reason, redactText) } : {}),
+ ...(readErrorText(mode.error) ? { error: redact(readErrorText(mode.error), redactText) } : {}),
+ ...(mode.has_consumable_image === true ? { has_consumable_image: true } : {}),
+ ...(mode.has_remote_url_result === true ? { has_remote_url_result: true } : {}),
+ ...(mode.has_same_origin_url_result === true ? { has_same_origin_url_result: true } : {})
+ };
+ return result;
+}
+
+function summarizePreflight(value, redactText) {
+ if (!isRecord(value)) return { ok: false, reason: 'not_reported' };
+ return {
+ ok: value.ok === true,
+ ...(value.skipped === true ? { skipped: true } : {}),
+ ...(readSafeInteger(value.status) !== undefined ? { status: value.status } : {}),
+ ...(readSafeInteger(value.elapsed_ms) !== undefined ? { elapsed_ms: value.elapsed_ms } : {}),
+ ...(readNonEmptyString(value.reason) ? { reason: redact(value.reason, redactText) } : {}),
+ ...(readErrorText(value.error) ? { error: redact(readErrorText(value.error), redactText) } : {})
+ };
+}
+
+function readResponsesModel(modeReports, responsesModes) {
+ for (const requestMode of responsesModes) {
+ const model = readNonEmptyString(modeReports[requestMode]?.responses_model);
+ if (model) return model;
+ }
+ return '';
+}
+
+function orderRequestModesByDefaultPriority(requestModes) {
+ const allowed = new Set(requestModes);
+ return DEFAULT_CHANNEL_CAPABILITY_REQUEST_MODE_PRIORITY.filter((requestMode) => allowed.has(requestMode));
+}
+
+function resolveDefaultImageBackend(requestModes) {
+ if (requestModes.some((requestMode) => requestMode.startsWith('images-'))) return 'images-api';
+ if (requestModes.some((requestMode) => requestMode.startsWith('responses-'))) return 'responses-image-generation';
+ return undefined;
+}
+
+function resolvePlainHttpAllowlistValue(baseUrl) {
+ const parsed = new URL(baseUrl);
+ if (parsed.protocol !== 'http:' || isLoopbackHostname(parsed.hostname)) return undefined;
+ return baseUrl;
+}
+
+function isLoopbackHostname(hostname) {
+ const normalized = hostname.toLowerCase();
+ return (
+ normalized === 'localhost' ||
+ normalized === '::1' ||
+ normalized === '[::1]' ||
+ /^127(?:\.\d{1,3}){3}$/.test(normalized)
+ );
+}
+
+function normalizeRequestModes(value) {
+ const modes = Array.isArray(value) ? value : [];
+ const normalized = CHANNEL_CAPABILITY_REQUEST_MODES.filter((requestMode) => modes.includes(requestMode));
+ if (normalized.length === 0) throw new Error('missing_request_modes');
+ return normalized;
+}
+
+function readPositiveChannelIndex(value) {
+ const text = String(value ?? '').trim();
+ if (!/^[1-9]\d*$/.test(text)) throw new Error('invalid_channel_index');
+ const parsed = Number(text);
+ if (!Number.isSafeInteger(parsed)) throw new Error('invalid_channel_index');
+ return parsed;
+}
+
+function readRequiredEnvValue(value, label) {
+ const normalized = readNonEmptyString(value);
+ if (!normalized || CONTROL_CHARACTER_PATTERN.test(normalized)) throw new Error(`invalid_${label}`);
+ return normalized;
+}
+
+function serializeEnvValue(value) {
+ const normalized = readRequiredEnvValue(value, 'env_value');
+ return SAFE_UNQUOTED_ENV_VALUE_PATTERN.test(normalized) ? normalized : JSON.stringify(normalized);
+}
+
+function readSafeInteger(value) {
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
+}
+
+function readErrorText(value) {
+ if (typeof value === 'string') return value;
+ if (isRecord(value) && typeof value.message === 'string') return value.message;
+ return '';
+}
+
+function redact(value, redactText) {
+ return typeof redactText === 'function' ? redactText(value) : value;
+}
+
+function readNonEmptyString(value) {
+ return typeof value === 'string' && value.trim() ? value.trim() : '';
+}
+
+function isRecord(value) {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+}
diff --git a/src/app/api/agent/agent-routes.test.ts b/src/app/api/agent/agent-routes.test.ts
index b96bc44c1ef552357d9cebea6e14e37123f7b801..56d1d8fa04021a0fd7ce894bf81e3d7d449e5874 100644
--- a/src/app/api/agent/agent-routes.test.ts
+++ b/src/app/api/agent/agent-routes.test.ts
@@ -59,6 +59,7 @@ beforeEach(async () => {
delete process.env.OPENAI_API_BASE_URL;
delete process.env.OPENAI_UPSTREAM_REQUEST_MODES;
delete process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY;
+ delete process.env.OPENAI_UPSTREAM_PROXY_URL;
delete process.env.OPENAI_UPSTREAM_USER_AGENT;
delete process.env.UPSTREAM_USER_AGENT;
delete process.env.OPENAI_CHANNEL_1_ID;
@@ -157,6 +158,7 @@ describe('Agent route integration', () => {
assert.deepEqual(body.upstream_request_headers.channels, [
{
id: 'matsca',
+ upstream_proxy: { configured: false },
request_modes: ['images-non-stream'],
request_mode_priority: ['images-non-stream'],
request_headers: {
@@ -211,6 +213,7 @@ describe('Agent route integration', () => {
assert.deepEqual(body.upstream_request_headers.channels, [
{
id: 'default',
+ upstream_proxy: { configured: false },
request_modes: ['images-non-stream', 'images-sse'],
request_mode_priority: ['images-sse', 'images-non-stream'],
request_headers: {
@@ -223,6 +226,40 @@ describe('Agent route integration', () => {
]);
});
+ it('reports global and per-channel upstream proxy summaries without exposing endpoints', async () => {
+ const { getCapabilities } = await loadAgentRoutes();
+ process.env.OPENAI_UPSTREAM_PROXY_URL = 'https://global-proxy.integration.example:9443';
+ process.env.OPENAI_CHANNEL_1_ID = 'primary';
+ process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://primary.example.com/v1';
+ process.env.OPENAI_CHANNEL_1_API_KEYS = 'primary-secret';
+ process.env.OPENAI_CHANNEL_2_ID = 'backup';
+ process.env.OPENAI_CHANNEL_2_BASE_URL = 'https://backup.example.com/v1';
+ process.env.OPENAI_CHANNEL_2_API_KEYS = 'backup-secret';
+ process.env.OPENAI_CHANNEL_2_PROXY_URL = 'http://channel-proxy.integration.example:8080';
+
+ const response = await getCapabilities();
+ assert.equal(response.status, 200);
+ const body = await response.json();
+ assert.deepEqual(body.image_transport.upstream_proxy, { configured: true, protocol: 'https' });
+ assert.deepEqual(
+ body.upstream_request_headers.channels.map((channel: { id: string; upstream_proxy: unknown }) => ({
+ id: channel.id,
+ upstream_proxy: channel.upstream_proxy
+ })),
+ [
+ { id: 'primary', upstream_proxy: { configured: true, protocol: 'https' } },
+ { id: 'backup', upstream_proxy: { configured: true, protocol: 'http' } }
+ ]
+ );
+ const serialized = JSON.stringify(body);
+ assert.equal(serialized.includes('global-proxy.integration.example'), false);
+ assert.equal(serialized.includes('channel-proxy.integration.example'), false);
+ assert.equal(serialized.includes('9443'), false);
+ assert.equal(serialized.includes('8080'), false);
+ assert.equal(serialized.includes('primary-secret'), false);
+ assert.equal(serialized.includes('backup-secret'), false);
+ });
+
it('registers cleanup-managed artifacts for every request mode', async () => {
const { generateImage } = await loadAgentRoutes();
const { getAgentStateStore } = await import('@/lib/agent-state-runtime');
@@ -389,6 +426,7 @@ describe('Agent route integration', () => {
assert.deepEqual(body.upstream_request_headers.channels, [
{
id: 'matsca',
+ upstream_proxy: { configured: false },
request_modes: ['images-non-stream'],
request_mode_priority: ['images-non-stream'],
request_headers: {
diff --git a/src/app/api/agent/capabilities/route.ts b/src/app/api/agent/capabilities/route.ts
index d66fb637c6b4e12f818c1df6360eb507ab606aa8..d10e0eda922fdc032af84498f7c841626fd38c89 100644
--- a/src/app/api/agent/capabilities/route.ts
+++ b/src/app/api/agent/capabilities/route.ts
@@ -31,6 +31,7 @@ function readPublicCapabilitiesEnv(): Record {
OPENAI_UPSTREAM_PROFILE: process.env.OPENAI_UPSTREAM_PROFILE,
OPENAI_UPSTREAM_REQUEST_MODES: process.env.OPENAI_UPSTREAM_REQUEST_MODES,
OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY: process.env.OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY,
+ OPENAI_UPSTREAM_PROXY_URL: process.env.OPENAI_UPSTREAM_PROXY_URL,
OPENAI_UPSTREAM_USER_AGENT: readConfiguredMarker(process.env.OPENAI_UPSTREAM_USER_AGENT),
UPSTREAM_USER_AGENT: readConfiguredMarker(process.env.UPSTREAM_USER_AGENT),
OPENAI_ROUTING_STRATEGY: process.env.OPENAI_ROUTING_STRATEGY,
@@ -46,7 +47,7 @@ function readPublicChannelEnv(env: NodeJS.ProcessEnv): Record = {};
for (const key of Object.keys(env)) {
const match =
- /^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(
+ /^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(
key
);
if (!match) continue;
diff --git a/src/app/api/agent/diagnostics/channel-health/route.test.ts b/src/app/api/agent/diagnostics/channel-health/route.test.ts
index 91ac16cd099647ebb7937676025c2a6eab514a5c..9fd74703343f11dff8a3d4e4ce27325ce6f1cb77 100644
--- a/src/app/api/agent/diagnostics/channel-health/route.test.ts
+++ b/src/app/api/agent/diagnostics/channel-health/route.test.ts
@@ -19,6 +19,8 @@ beforeEach(() => {
process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://images.example.test/v1';
process.env.OPENAI_CHANNEL_1_API_KEYS = 'health-channel-secret,secondary-secret';
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream,images-sse';
+ delete process.env.OPENAI_UPSTREAM_PROXY_URL;
+ delete process.env.OPENAI_CHANNEL_1_PROXY_URL;
resetServerChannelStateForTests();
});
@@ -104,6 +106,7 @@ describe('GET /api/agent/diagnostics/channel-health', () => {
channels: [
{
channel_id: 'primary',
+ upstream_proxy: { configured: false },
credential_count: 2,
healthy_credential_count: 1,
unhealthy_credential_count: 1,
diff --git a/src/app/api/agent/diagnostics/channel-health/route.ts b/src/app/api/agent/diagnostics/channel-health/route.ts
index b184d1942e7b0152a99a1b307f8c765be222ffe5..9116fb1e371a86adf497f361d7401490f111a34a 100644
--- a/src/app/api/agent/diagnostics/channel-health/route.ts
+++ b/src/app/api/agent/diagnostics/channel-health/route.ts
@@ -33,6 +33,7 @@ function toPublicChannelHealthSnapshot(snapshot: ChannelHealthSnapshot) {
observed_at: snapshot.at,
channels: snapshot.channels.map((channel) => ({
channel_id: channel.channelId,
+ upstream_proxy: channel.upstreamProxy,
credential_count: channel.credentialCount,
healthy_credential_count: channel.healthyCredentialCount,
unhealthy_credential_count: channel.unhealthyCredentialCount,
diff --git a/src/app/api/deploy-marker/route.ts b/src/app/api/deploy-marker/route.ts
index 77704f51efab1a66d8274c0651e51ea35090f434..b73211b9b5db234c7b069b4962736b9564866a31 100644
--- a/src/app/api/deploy-marker/route.ts
+++ b/src/app/api/deploy-marker/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from 'next/server';
-const deployMarker = {"schema_version":1,"local_sha":"7db5c07cd25ba69e829f2561814fec0a1a73e7e8","created_at":"2026-07-23T14:28:33.665Z","deploy_id":"e28ac5a2-35c9-4be8-a553-2f3632c02d2e"} as const;
+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;
export const dynamic = 'force-dynamic';
diff --git a/src/app/api/images/route-defaults.test.ts b/src/app/api/images/route-defaults.test.ts
index e2b86dad8dd58b2d7810d67bad264a27a383e2ca..6e5f432da9c9a1459f58e721222db8276e6e70a5 100644
--- a/src/app/api/images/route-defaults.test.ts
+++ b/src/app/api/images/route-defaults.test.ts
@@ -2,6 +2,7 @@ import {
PNG_BASE64,
imageFormRequest,
readSseEvents,
+ startHttpConnectProxy,
startImagesJsonUpstream,
startResponsesImageUpstream,
startStreamingImageUpstream,
@@ -111,6 +112,62 @@ describe('POST /api/images backend defaults and security boundaries', { concurre
}
});
+ it('sends page API requests through the configured global upstream proxy', async () => {
+ const { POST } = await import('./route');
+ const upstream = await startImagesJsonUpstream(async () => ({ data: [{ b64_json: PNG_BASE64 }] }));
+ const proxy = await startHttpConnectProxy();
+ process.env.OPENAI_UPSTREAM_PROXY_URL = proxy.url;
+
+ try {
+ const response = await POST(
+ imageFormRequest({
+ apiBaseUrl: upstream.baseUrl,
+ apiKey: 'test-key',
+ stream: false,
+ streamMode: 'non_stream'
+ })
+ );
+
+ assert.equal(response.status, 200);
+ assert.ok(proxy.connectTargets.length > 0);
+ assert.ok(proxy.connectTargets.every((target) => target === new URL(upstream.baseUrl).host));
+ } finally {
+ await proxy.close();
+ await upstream.close();
+ }
+ });
+
+ it('uses a channel proxy instead of the global upstream proxy', async () => {
+ const { POST } = await import('./route');
+ const upstream = await startImagesJsonUpstream(async () => ({ data: [{ b64_json: PNG_BASE64 }] }));
+ const globalProxy = await startHttpConnectProxy();
+ const channelProxy = await startHttpConnectProxy();
+ process.env.OPENAI_UPSTREAM_PROXY_URL = globalProxy.url;
+ process.env.OPENAI_CHANNEL_1_ID = 'proxied-channel';
+ process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
+ process.env.OPENAI_CHANNEL_1_API_KEYS = 'channel-key';
+ process.env.OPENAI_CHANNEL_1_PROXY_URL = channelProxy.url;
+ process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
+
+ try {
+ const response = await POST(
+ imageFormRequest({
+ stream: false,
+ streamMode: 'non_stream'
+ })
+ );
+
+ assert.equal(response.status, 200);
+ assert.ok(channelProxy.connectTargets.length > 0);
+ assert.ok(channelProxy.connectTargets.every((target) => target === new URL(upstream.baseUrl).host));
+ assert.deepEqual(globalProxy.connectTargets, []);
+ } finally {
+ await channelProxy.close();
+ await globalProxy.close();
+ await upstream.close();
+ }
+ });
+
it('rejects cross-origin GPT2Image URL results before downloading them', async () => {
const { POST } = await import('./route');
const upstream = await startImagesJsonUpstream(async () => {
diff --git a/src/app/api/images/route-test-helpers.ts b/src/app/api/images/route-test-helpers.ts
index d8ff7de3d069435689ccdf400291e362a7d9bb2d..cca0c1fe981c9d06c518e6f2573f710244199d41 100644
--- a/src/app/api/images/route-test-helpers.ts
+++ b/src/app/api/images/route-test-helpers.ts
@@ -1,6 +1,7 @@
import type { NextRequest } from 'next/server';
import assert from 'node:assert/strict';
import http from 'node:http';
+import net from 'node:net';
export { readSseEvents } from '@/lib/sse-test-utils';
@@ -405,6 +406,56 @@ export async function startStreamingResponsesImageUpstream(
return listen(server);
}
+export async function startHttpConnectProxy(): Promise<{
+ url: string;
+ connectTargets: string[];
+ close: () => Promise;
+}> {
+ const sockets = new Set();
+ const connectTargets: string[] = [];
+ const server = http.createServer((_request, response) => {
+ response.writeHead(405, { Connection: 'close' });
+ response.end();
+ });
+ server.on('connection', (socket) => {
+ sockets.add(socket);
+ socket.on('close', () => sockets.delete(socket));
+ });
+ server.on('connect', (request, clientSocket, head) => {
+ const target = parseConnectTarget(request.url);
+ if (!target) {
+ clientSocket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
+ return;
+ }
+ connectTargets.push(target.host);
+ const targetSocket = net.connect({ host: target.hostname, port: Number(target.port) });
+ sockets.add(targetSocket);
+ targetSocket.on('close', () => sockets.delete(targetSocket));
+ targetSocket.once('connect', () => {
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
+ if (head.length > 0) targetSocket.write(head);
+ clientSocket.pipe(targetSocket);
+ targetSocket.pipe(clientSocket);
+ });
+ targetSocket.once('error', () => {
+ if (!clientSocket.destroyed) {
+ clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
+ }
+ });
+ });
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+ const address = server.address();
+ assert.ok(address && typeof address === 'object');
+ return {
+ url: `http://127.0.0.1:${address.port}`,
+ connectTargets,
+ close: () => closeServerWithSockets(server, sockets)
+ };
+}
+
async function listen(server: http.Server): Promise<{ baseUrl: string; close: () => Promise }> {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
@@ -414,3 +465,22 @@ async function listen(server: http.Server): Promise<{ baseUrl: string; close: ()
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())))
};
}
+
+function parseConnectTarget(rawTarget: string | undefined): URL | undefined {
+ if (!rawTarget) return undefined;
+ try {
+ return new URL(`http://${rawTarget}`);
+ } catch {
+ return undefined;
+ }
+}
+
+async function closeServerWithSockets(server: http.Server, sockets: Set): Promise {
+ for (const socket of sockets) socket.destroy();
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve();
+ });
+ });
+}
diff --git a/src/app/api/images/route-test-setup.ts b/src/app/api/images/route-test-setup.ts
index e99ba7739cf9dac3ea97a58fa47582cdc1644bcb..cb07758ab8b70428ed912b9eafa802483e1d3366 100644
--- a/src/app/api/images/route-test-setup.ts
+++ b/src/app/api/images/route-test-setup.ts
@@ -16,6 +16,7 @@ export function registerRouteTestLifecycle() {
delete process.env.APP_PASSWORD;
delete process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_BASE_URL;
+ delete process.env.OPENAI_UPSTREAM_PROXY_URL;
delete process.env.OPENAI_CHANNEL_1_ID;
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts
index 0dbb9b25956ee0f1c626046d92c6d8a9a59847c9..3195c05a5cc7fd80c504d3664e7c9ca5c569a177 100755
--- a/src/app/api/images/route.ts
+++ b/src/app/api/images/route.ts
@@ -427,6 +427,7 @@ export async function POST(request: NextRequest) {
const {
apiKey: effectiveApiKey,
baseUrl: effectiveApiBaseUrl,
+ upstreamProxyUrl: effectiveUpstreamProxyUrl,
upstreamProfile: effectiveUpstreamProfileId,
providerProfile,
upstreamHeaders,
@@ -435,6 +436,7 @@ export async function POST(request: NextRequest) {
requestApiKey,
requestApiBaseUrl,
legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
+ legacyUpstreamProxyUrl: process.env.OPENAI_UPSTREAM_PROXY_URL,
selectedCredential: selectedServerCredential
});
validateApiBaseUrl(effectiveApiBaseUrl || '', { allowedPlainHttpBaseUrls });
@@ -461,6 +463,7 @@ export async function POST(request: NextRequest) {
createOpenAIImageClientOptions({
apiKey: effectiveApiKey,
baseURL: effectiveApiBaseUrl || undefined,
+ upstreamProxyUrl: effectiveUpstreamProxyUrl,
defaultHeaders: mergeUpstreamHeadersWithFixed(upstreamHeaders, {})
})
);
@@ -577,6 +580,7 @@ export async function POST(request: NextRequest) {
storageMode: effectiveStorageMode,
apiBaseUrl: effectiveApiBaseUrl,
apiKey: effectiveApiKey,
+ upstreamProxyUrl: effectiveUpstreamProxyUrl,
startedAtMs: upstreamStartedAtMs,
upstreamIdempotencyKey,
clientRequestId,
@@ -609,6 +613,7 @@ export async function POST(request: NextRequest) {
storageMode: effectiveStorageMode,
apiBaseUrl: effectiveApiBaseUrl,
apiKey: effectiveApiKey,
+ upstreamProxyUrl: effectiveUpstreamProxyUrl,
startedAtMs: upstreamStartedAtMs,
upstreamIdempotencyKey,
clientRequestId,
@@ -647,6 +652,7 @@ export async function POST(request: NextRequest) {
normalizeOutputFormat: true,
apiBaseUrl: effectiveApiBaseUrl,
apiKey: effectiveApiKey,
+ upstreamProxyUrl: effectiveUpstreamProxyUrl,
upstreamHeaders,
abortSignal: request.signal
});
@@ -657,6 +663,7 @@ export async function POST(request: NextRequest) {
const actualCost = await resolveRequestActualCostSafely({
apiBaseUrl: effectiveApiBaseUrl,
apiKey: effectiveApiKey,
+ upstreamProxyUrl: effectiveUpstreamProxyUrl,
model,
startedAtMs: upstreamStartedAtMs,
expectedImageCount: savedImagesData.length,
diff --git a/src/app/api/runtime-capabilities/route.test.ts b/src/app/api/runtime-capabilities/route.test.ts
index 559907867754f4922a3774b4c3a83221dd09d7d8..3806e095cfba460e357b72c1f69ad8ff7f59de45 100644
--- a/src/app/api/runtime-capabilities/route.test.ts
+++ b/src/app/api/runtime-capabilities/route.test.ts
@@ -33,6 +33,7 @@ beforeEach(async () => {
delete process.env.IMAGE_UPSTREAM_TIMEOUT_MS;
delete process.env.IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS;
delete process.env.IMAGE_UPSTREAM_MAX_RETRIES;
+ delete process.env.OPENAI_UPSTREAM_PROXY_URL;
delete process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_BASE_URL;
delete process.env.OPENAI_ROUTING_STRATEGY;
@@ -43,6 +44,7 @@ beforeEach(async () => {
delete process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST;
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES;
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODE_PRIORITY;
+ delete process.env.OPENAI_CHANNEL_1_PROXY_URL;
delete process.env.OPENAI_CHANNEL_2_ID;
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
@@ -50,6 +52,7 @@ beforeEach(async () => {
delete process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST;
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODES;
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODE_PRIORITY;
+ delete process.env.OPENAI_CHANNEL_2_PROXY_URL;
delete process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED;
delete process.env.OPENAI_CHANNEL_QUEUE_ENABLED;
delete process.env.OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS;
@@ -215,6 +218,7 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
upstream_timeout_ms?: number;
stream_data_interval_timeout_ms?: number;
upstream_max_retries?: number;
+ upstream_proxy?: { configured: boolean; protocol?: string };
}
>;
@@ -224,7 +228,8 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
assert.deepEqual(body.imageTransport, {
upstream_timeout_ms: 1_200_000,
stream_data_interval_timeout_ms: 600_000,
- upstream_max_retries: 1
+ upstream_max_retries: 1,
+ upstream_proxy: { configured: false }
});
});
@@ -612,6 +617,10 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
configuredChannelCount: number;
healthyChannelCount: number;
}>;
+ upstreamProxyByChannel: Array<{
+ channelId: string;
+ upstreamProxy: { configured: boolean; protocol?: string };
+ }>;
requestModesByChannel: Array<{
channelId: string;
requestModes: string[];
@@ -689,6 +698,12 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
healthyChannelCount: 0
}
],
+ upstreamProxyByChannel: [
+ {
+ channelId: 'images',
+ upstreamProxy: { configured: false }
+ }
+ ],
requestModesByChannel: [
{
channelId: 'images',
diff --git a/src/app/api/runtime-capabilities/route.ts b/src/app/api/runtime-capabilities/route.ts
index d03ebd4ecd274e828c31692230461c9687c4c6d4..50188d31f7f98277df234dab72051d66fe54f157 100644
--- a/src/app/api/runtime-capabilities/route.ts
+++ b/src/app/api/runtime-capabilities/route.ts
@@ -98,6 +98,10 @@ export async function GET() {
CHANNEL_REQUEST_MODE_ADMIN_CONTROL.defaultPriority,
requestModeControls: CHANNEL_REQUEST_MODE_ADMIN_CONTROL,
requestModeHealth: requestModeHealthSummary?.modes ?? [],
+ upstreamProxyByChannel: summary.channels.map((channel) => ({
+ channelId: channel.id,
+ upstreamProxy: channel.upstreamProxy
+ })),
requestModesByChannel: summary.channels.map((channel) => ({
channelId: channel.id,
requestModes: channel.requestModes,
diff --git a/src/lib/agent-api-contracts.test.ts b/src/lib/agent-api-contracts.test.ts
index 374cd0d36c9c1c1b40c12dfc18795a8b0c5141f7..2ff422f78e7aaa60c75046f05584dced47fe31b9 100644
--- a/src/lib/agent-api-contracts.test.ts
+++ b/src/lib/agent-api-contracts.test.ts
@@ -394,7 +394,8 @@ describe('buildAgentCapabilities', () => {
assert.deepEqual(capabilities.image_transport, {
upstream_timeout_ms: 900_000,
stream_data_interval_timeout_ms: 900_000,
- upstream_max_retries: 0
+ upstream_max_retries: 0,
+ upstream_proxy: { configured: false }
});
assert.equal(capabilities.defaults.image_backend, 'images-api');
assert.equal(capabilities.defaults.stream_mode, 'auto');
@@ -713,6 +714,7 @@ describe('buildAgentCapabilities', () => {
assert.deepEqual(capabilities.upstream_request_headers.channels, [
{
id: 'images',
+ upstream_proxy: { configured: false },
request_modes: ['images-non-stream', 'images-sse'],
request_mode_priority: ['images-non-stream', 'images-sse'],
request_headers: {
@@ -725,6 +727,36 @@ describe('buildAgentCapabilities', () => {
]);
});
+ it('reports global and per-channel upstream proxy summaries without exposing endpoints', () => {
+ const capabilities = buildAgentCapabilities({
+ OPENAI_UPSTREAM_PROXY_URL: 'https://global-proxy.internal.example:9443',
+ OPENAI_CHANNEL_1_ID: 'primary',
+ OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1',
+ OPENAI_CHANNEL_1_API_KEYS: 'configured',
+ OPENAI_CHANNEL_2_ID: 'backup',
+ OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1',
+ OPENAI_CHANNEL_2_API_KEYS: 'configured',
+ OPENAI_CHANNEL_2_PROXY_URL: 'http://channel-proxy.internal.example:8080'
+ });
+
+ assert.deepEqual(capabilities.image_transport.upstream_proxy, { configured: true, protocol: 'https' });
+ assert.deepEqual(
+ capabilities.upstream_request_headers.channels.map((channel) => ({
+ id: channel.id,
+ upstream_proxy: channel.upstream_proxy
+ })),
+ [
+ { id: 'primary', upstream_proxy: { configured: true, protocol: 'https' } },
+ { id: 'backup', upstream_proxy: { configured: true, protocol: 'http' } }
+ ]
+ );
+ const serialized = JSON.stringify(capabilities);
+ assert.equal(serialized.includes('global-proxy.internal.example'), false);
+ assert.equal(serialized.includes('channel-proxy.internal.example'), false);
+ assert.equal(serialized.includes('9443'), false);
+ assert.equal(serialized.includes('8080'), false);
+ });
+
it('reports Matsca server-channel upload and image-count limits in Agent capabilities', () => {
const capabilities = buildAgentCapabilities({
OPENAI_CHANNEL_1_ID: 'matsca',
@@ -989,6 +1021,7 @@ describe('buildAgentCapabilities', () => {
assert.ok('AgentImageResponseTiming' in document.components.schemas);
assert.ok('AgentImageResponseExecution' in document.components.schemas);
assert.ok('ChannelRequestModeDecision' in document.components.schemas);
+ assert.ok('UpstreamProxySummary' in document.components.schemas);
assert.deepEqual(document.components.schemas.AgentImageResponseExecution.properties.channel_request_mode.enum, [
'images-non-stream',
'images-sse',
@@ -1108,6 +1141,11 @@ describe('buildAgentCapabilities', () => {
document.components.schemas.ImageTransportCapabilities.properties.upstream_timeout_ms.const,
900000
);
+ assert.equal(
+ document.components.schemas.ImageTransportCapabilities.properties.upstream_proxy.$ref,
+ '#/components/schemas/UpstreamProxySummary'
+ );
+ assert.deepEqual(document.components.schemas.UpstreamProxySummary.required, ['configured']);
assert.equal(
capabilityProperties.upstream_request_headers.properties.default.$ref,
'#/components/schemas/UpstreamRequestHeaderSummary'
diff --git a/src/lib/agent-api-contracts.ts b/src/lib/agent-api-contracts.ts
index c9c3ac7615bbbf32b79a30a38338727cc4f61ed3..c845aea8ff5699429ca4d457ca16c8ebd6fa6513 100644
--- a/src/lib/agent-api-contracts.ts
+++ b/src/lib/agent-api-contracts.ts
@@ -36,7 +36,7 @@ import {
type ImageStreamMode,
type ImageStreamingStrategy
} from './image-upstream-strategy';
-import { summarizeOpenAIImageTransport } from './openai-image-transport';
+import { summarizeOpenAIImageTransport, type UpstreamProxySummary } from './openai-image-transport';
import { CHINESE_POSITIVE_INTEGER_MESSAGES, readPositiveIntegerFromEnv } from './positive-integer-config.mjs';
import { readBooleanEnv } from './server-runtime';
import {
@@ -237,12 +237,14 @@ export type AgentCapabilities = {
upstream_timeout_ms: number;
stream_data_interval_timeout_ms: number;
upstream_max_retries: number;
+ upstream_proxy: UpstreamProxySummary;
};
upstream_profile: ImageUpstreamProfileSummary;
upstream_request_headers: {
default: UpstreamRequestHeaderSummary;
channels: Array<{
id: string;
+ upstream_proxy: UpstreamProxySummary;
request_modes: readonly ChannelRequestMode[];
request_mode_priority: readonly ChannelRequestMode[];
request_headers: UpstreamRequestHeaderSummary;
@@ -1331,6 +1333,7 @@ function buildAgentUpstreamRequestHeadersCapabilities(
default: summarizeUpstreamRequestHeaders(undefined, env),
channels: channelSummary.channels.map((channel) => ({
id: channel.id,
+ upstream_proxy: channel.upstreamProxy,
request_modes: channel.requestModes,
request_mode_priority: channel.requestModePriority,
request_headers: channel.requestHeaders
diff --git a/src/lib/agent-image-service.ts b/src/lib/agent-image-service.ts
index 4d1c49767397904de0f7b225c7a0d86e74024a20..b40d24b27501d6ea1a24801d37a089447e463d06 100644
--- a/src/lib/agent-image-service.ts
+++ b/src/lib/agent-image-service.ts
@@ -118,6 +118,7 @@ type CredentialContext = {
channelRequestModeDecision: ChannelRequestModeDecision;
baseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
upstreamProfile: ImageUpstreamProfile;
upstreamHeaders?: UpstreamRequestHeaders;
};
@@ -469,6 +470,7 @@ export async function executeAgentGenerate(options: {
cached: options.cached,
apiBaseUrl: credentialContext.baseUrl,
apiKey: credentialContext.apiKey,
+ upstreamProxyUrl: credentialContext.upstreamProxyUrl,
upstreamHeaders: credentialContext.upstreamHeaders,
execution: {
startedAtMs,
@@ -559,6 +561,7 @@ async function executeAgentGenerateUpstream(
const stream = await createImagesApiGenerateStream({
apiBaseUrl: credentialContext.baseUrl,
apiKey: credentialContext.apiKey,
+ upstreamProxyUrl: credentialContext.upstreamProxyUrl,
upstreamHeaders: credentialContext.upstreamHeaders,
idempotencyKey,
abortSignal,
@@ -571,6 +574,7 @@ async function executeAgentGenerateUpstream(
return await collectOpenAiImagesFromStream(stream, {
apiBaseUrl: credentialContext.baseUrl,
apiKey: credentialContext.apiKey,
+ upstreamProxyUrl: credentialContext.upstreamProxyUrl,
upstreamHeaders: credentialContext.upstreamHeaders,
abortSignal,
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200)
@@ -667,6 +671,7 @@ async function executeAgentResponsesGenerate(
{
apiBaseUrl: credentialContext.baseUrl,
apiKey: credentialContext.apiKey,
+ upstreamProxyUrl: credentialContext.upstreamProxyUrl,
upstreamHeaders: credentialContext.upstreamHeaders,
abortSignal,
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(streamOptions, reason, 200)
@@ -837,6 +842,7 @@ export async function executeAgentEdit(options: {
cached: options.cached,
apiBaseUrl: activeCredentialContext.baseUrl,
apiKey: activeCredentialContext.apiKey,
+ upstreamProxyUrl: activeCredentialContext.upstreamProxyUrl,
upstreamHeaders: activeCredentialContext.upstreamHeaders,
execution: {
startedAtMs,
@@ -900,6 +906,7 @@ async function executeAgentEditStream(input: {
return await collectOpenAiImagesFromStream(stream, {
apiBaseUrl: input.credentialContext.baseUrl,
apiKey: input.credentialContext.apiKey,
+ upstreamProxyUrl: input.credentialContext.upstreamProxyUrl,
upstreamHeaders: input.credentialContext.upstreamHeaders,
abortSignal: input.abortSignal,
onStreamingDegraded: (reason) => markAgentStreamingUnavailable(input.streamOptions, reason, 200)
@@ -1072,12 +1079,14 @@ function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelReque
const {
apiKey,
baseUrl,
+ upstreamProxyUrl,
providerProfile,
selectedCredential: effectiveSelectedCredential
} = resolveEffectiveCredential({
requestApiKey: '',
requestApiBaseUrl: '',
legacyBaseUrl: process.env.OPENAI_API_BASE_URL,
+ legacyUpstreamProxyUrl: process.env.OPENAI_UPSTREAM_PROXY_URL,
selectedCredential
});
validateApiBaseUrl(baseUrl || '', {
@@ -1105,6 +1114,7 @@ function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelReque
createOpenAIImageClientOptions({
apiKey,
baseURL: baseUrl || undefined,
+ upstreamProxyUrl,
defaultHeaders: mergeUpstreamHeadersWithFixed(effectiveSelectedCredential?.upstreamHeaders, {})
})
),
@@ -1114,6 +1124,7 @@ function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelReque
channelRequestModeDecision,
baseUrl,
apiKey,
+ upstreamProxyUrl,
upstreamProfile:
providerProfile ||
readImageUpstreamProfile({
@@ -1230,6 +1241,7 @@ async function persistOpenAiImages(options: {
cached: boolean;
apiBaseUrl?: string;
apiKey?: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
execution: AgentExecutionMetadata;
abortSignal?: AbortSignal;
@@ -1244,6 +1256,7 @@ async function persistOpenAiImages(options: {
normalizeOutputFormat: options.normalizeOutputFormat,
apiBaseUrl: options.apiBaseUrl,
apiKey: options.apiKey,
+ upstreamProxyUrl: options.upstreamProxyUrl,
upstreamHeaders: options.upstreamHeaders,
abortSignal: options.abortSignal
});
diff --git a/src/lib/agent-openapi.ts b/src/lib/agent-openapi.ts
index ccc9b3e735deb9f65c0fdee54c1cf084c76dc26e..fbae31701f36a851ed6334250445b98af26dd9c4 100644
--- a/src/lib/agent-openapi.ts
+++ b/src/lib/agent-openapi.ts
@@ -504,9 +504,16 @@ export function buildAgentOpenApiDocument(env: Record Promise;
type ProbeResult = {
@@ -69,14 +70,18 @@ export async function probeChannelModelsEndpoint(input: {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), input.timeoutMs);
try {
- const response = await (input.fetchImpl || fetch)(buildModelsUrl(input.credential.baseUrl), {
+ const requestInit = {
method: 'GET',
headers: mergeUpstreamHeadersWithFixed(input.credential.upstreamHeaders, {
Authorization: `Bearer ${input.credential.apiKey}`,
Accept: 'application/json'
}),
signal: abortController.signal
- });
+ } satisfies RequestInit;
+ const targetUrl = buildModelsUrl(input.credential.baseUrl);
+ const response = input.fetchImpl
+ ? await input.fetchImpl(targetUrl, requestInit)
+ : await fetchOpenAIUpstream(targetUrl, requestInit, input.credential.upstreamProxyUrl);
if (!response.ok) {
return {
ok: false,
diff --git a/src/lib/channel-health-snapshot.test.ts b/src/lib/channel-health-snapshot.test.ts
index 46be1473e71e25f6d594ed5b492b2e04acf2e73e..cd9ac00257b95c588438e05bcca235fd99300939 100644
--- a/src/lib/channel-health-snapshot.test.ts
+++ b/src/lib/channel-health-snapshot.test.ts
@@ -35,6 +35,7 @@ describe('channel health snapshot', () => {
channels: [
{
channelId: 'primary',
+ upstreamProxy: { configured: false },
credentialCount: 2,
healthyCredentialCount: 1,
unhealthyCredentialCount: 1,
diff --git a/src/lib/channel-router.test.ts b/src/lib/channel-router.test.ts
index f88b8c5cd2d86009580f4a0d7f4942e519b60f2e..acbfdd7ba1c7344c149cc2e0319a1c7352e3fba2 100644
--- a/src/lib/channel-router.test.ts
+++ b/src/lib/channel-router.test.ts
@@ -83,6 +83,61 @@ describe('parseChannelPoolConfig', () => {
]);
});
+ it('applies the global proxy and lets a numbered channel override it', () => {
+ const config = parseChannelPoolConfig({
+ OPENAI_UPSTREAM_PROXY_URL: 'https://global-proxy.example:8443',
+ OPENAI_CHANNEL_1_ID: 'primary',
+ OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1',
+ OPENAI_CHANNEL_1_API_KEYS: 'sk-primary',
+ OPENAI_CHANNEL_2_ID: 'backup',
+ OPENAI_CHANNEL_2_BASE_URL: 'https://backup.example.com/v1',
+ OPENAI_CHANNEL_2_API_KEYS: 'sk-backup',
+ OPENAI_CHANNEL_2_PROXY_URL: 'http://channel-proxy.example:8080'
+ });
+
+ assert.deepEqual(
+ config.credentials.map((credential) => ({
+ id: credential.id,
+ upstreamProxyUrl: credential.upstreamProxyUrl
+ })),
+ [
+ { id: 'primary#0', upstreamProxyUrl: 'https://global-proxy.example:8443/' },
+ { id: 'backup#0', upstreamProxyUrl: 'http://channel-proxy.example:8080/' }
+ ]
+ );
+ const summary = getChannelPoolSummary(config);
+ assert.deepEqual(
+ summary.channels.map((channel) => ({ id: channel.id, upstreamProxy: channel.upstreamProxy })),
+ [
+ { id: 'primary', upstreamProxy: { configured: true, protocol: 'https' } },
+ { id: 'backup', upstreamProxy: { configured: true, protocol: 'http' } }
+ ]
+ );
+ assert.equal(JSON.stringify(summary).includes('global-proxy.example'), false);
+ assert.equal(JSON.stringify(summary).includes('channel-proxy.example'), false);
+ });
+
+ it('rejects an invalid global or channel proxy URL explicitly', () => {
+ assert.throws(
+ () =>
+ parseChannelPoolConfig({
+ OPENAI_API_KEY: 'sk-legacy',
+ OPENAI_API_BASE_URL: 'https://legacy.example.com/v1',
+ OPENAI_UPSTREAM_PROXY_URL: 'ftp://proxy.example'
+ }),
+ /OPENAI_UPSTREAM_PROXY_URL/
+ );
+ assert.throws(
+ () =>
+ parseChannelPoolConfig({
+ OPENAI_CHANNEL_1_BASE_URL: 'https://primary.example.com/v1',
+ OPENAI_CHANNEL_1_API_KEYS: 'sk-primary',
+ OPENAI_CHANNEL_1_PROXY_URL: 'http://proxy.example/path'
+ }),
+ /OPENAI_CHANNEL_1_PROXY_URL/
+ );
+ });
+
it('rejects invalid legacy upstream profile values instead of silently using the default profile', () => {
assert.throws(
() =>
@@ -236,6 +291,7 @@ describe('getChannelPoolSummary', () => {
{
id: 'official',
baseUrl: 'https://api.openai.com/v1',
+ upstreamProxy: { configured: false },
upstreamProfile: 'openai-compatible',
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
hasExtraHeaders: false,
@@ -247,6 +303,7 @@ describe('getChannelPoolSummary', () => {
{
id: 'backup',
baseUrl: 'https://backup.example.com/v1',
+ upstreamProxy: { configured: false },
upstreamProfile: 'openai-compatible',
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
hasExtraHeaders: false,
@@ -277,6 +334,7 @@ describe('getChannelPoolSummary', () => {
{
id: 'matsca',
baseUrl: 'https://matsca.example.com/v1',
+ upstreamProxy: { configured: false },
upstreamProfile: 'matsca',
effectiveProfile: IMAGE_UPSTREAM_PROFILES.matsca,
hasExtraHeaders: true,
@@ -390,6 +448,7 @@ describe('getChannelPoolSummary', () => {
{
id: 'custom',
baseUrl: 'https://custom.example.com/v1',
+ upstreamProxy: { configured: false },
upstreamProfile: 'matsca',
effectiveProfile: config.credentials[0]?.providerProfile,
hasExtraHeaders: false,
@@ -1681,6 +1740,7 @@ describe('resolveEffectiveCredential', () => {
requestApiKey: 'sk-browser',
requestApiBaseUrl: '',
legacyBaseUrl: 'https://legacy.example.com/v1',
+ legacyUpstreamProxyUrl: 'http://proxy.example:8080/',
selectedCredential: {
id: 'server#0',
channelId: 'server',
@@ -1693,6 +1753,7 @@ describe('resolveEffectiveCredential', () => {
assert.deepEqual(credential, {
apiKey: 'sk-browser',
baseUrl: 'https://legacy.example.com/v1',
+ upstreamProxyUrl: 'http://proxy.example:8080/',
upstreamProfile: 'openai-compatible'
});
});
diff --git a/src/lib/channel-router.ts b/src/lib/channel-router.ts
index 78939719bdc8025afd2ee020fad502fd4b37668b..6a3d5dd0f22f6c64f314db9b5d28a3144b11b36d 100644
--- a/src/lib/channel-router.ts
+++ b/src/lib/channel-router.ts
@@ -29,6 +29,11 @@ import {
type ImageProviderManifest,
type ImageProviderManifestSummary
} from './image-upstream-provider-manifest';
+import {
+ readOpenAIUpstreamProxyUrl,
+ summarizeOpenAIUpstreamProxy,
+ type UpstreamProxySummary
+} from './openai-image-transport';
export type RoutingStrategy = 'sticky' | 'round_robin' | 'random';
@@ -37,6 +42,7 @@ export type ChannelCredential = {
channelId: string;
apiKey: string;
baseUrl?: string;
+ upstreamProxyUrl?: string;
upstreamProfile: ImageUpstreamProfileId;
upstreamHeaders?: UpstreamRequestHeaders;
providerManifest?: ImageProviderManifestSummary;
@@ -61,6 +67,7 @@ export type ChannelPoolSummary = {
channels: Array<{
id: string;
baseUrl?: string;
+ upstreamProxy: UpstreamProxySummary;
upstreamProfile: ImageUpstreamProfileId;
effectiveProfile: ImageUpstreamProfile;
hasExtraHeaders: boolean;
@@ -167,6 +174,7 @@ export type ChannelHealthSnapshot = {
at: number;
channels: Array<{
channelId: string;
+ upstreamProxy: UpstreamProxySummary;
credentialCount: number;
healthyCredentialCount: number;
unhealthyCredentialCount: number;
@@ -186,6 +194,7 @@ export type ChannelRecoveryProbeCandidate = {
export type EffectiveCredential = {
apiKey?: string;
baseUrl?: string;
+ upstreamProxyUrl?: string;
upstreamProfile: ImageUpstreamProfileId;
providerProfile?: ImageUpstreamProfile;
upstreamHeaders?: UpstreamRequestHeaders;
@@ -204,7 +213,7 @@ const DEFAULT_STRATEGY: RoutingStrategy = 'sticky';
const DEFAULT_FAILURE_COOLDOWN_MS = 30_000;
const VALID_STRATEGIES = new Set(['sticky', 'round_robin', 'random']);
const CHANNEL_KEY_PATTERN =
- /^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)$/;
+ /^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)$/;
export function parseChannelPoolConfig(env: Record): ChannelPoolConfig {
if (env.OPENAI_CHANNELS_JSON?.trim()) {
@@ -214,9 +223,10 @@ export function parseChannelPoolConfig(env: Record):
);
}
+ const globalUpstreamProxyUrl = readOpenAIUpstreamProxyUrl(env);
const channelIndexes = readConfiguredChannelIndexes(env);
if (channelIndexes.length === 0) {
- return parseLegacyConfig(env);
+ return parseLegacyConfig(env, globalUpstreamProxyUrl);
}
const strategy = readStrategy(env.OPENAI_ROUTING_STRATEGY, 'OPENAI_ROUTING_STRATEGY');
@@ -225,7 +235,7 @@ export function parseChannelPoolConfig(env: Record):
'OPENAI_UPSTREAM_REQUEST_MODE_PRIORITY'
);
const credentials = channelIndexes.flatMap((channelIndex) =>
- parseNumberedChannel(env, channelIndex, requestModePriority)
+ parseNumberedChannel(env, channelIndex, requestModePriority, globalUpstreamProxyUrl)
);
if (credentials.length === 0) {
@@ -769,6 +779,7 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
const healthyCredentialCount = credentials.filter((credential) => credential.state === 'healthy').length;
return {
channelId,
+ upstreamProxy: summarizeOpenAIUpstreamProxy(channelCredentials[0]?.upstreamProxyUrl),
credentialCount: credentials.length,
healthyCredentialCount,
unhealthyCredentialCount: credentials.length - healthyCredentialCount,
@@ -985,6 +996,7 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
{
id: string;
baseUrl?: string;
+ upstreamProxy: UpstreamProxySummary;
upstreamProfile: ImageUpstreamProfileId;
effectiveProfile: ImageUpstreamProfile;
hasExtraHeaders: boolean;
@@ -1005,6 +1017,7 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
channels.set(credential.channelId, {
id: credential.channelId,
baseUrl: credential.baseUrl,
+ upstreamProxy: summarizeOpenAIUpstreamProxy(credential.upstreamProxyUrl),
upstreamProfile: credential.upstreamProfile,
effectiveProfile: credential.providerProfile || IMAGE_UPSTREAM_PROFILES[credential.upstreamProfile],
hasExtraHeaders: Boolean(credential.upstreamHeaders),
@@ -1085,6 +1098,7 @@ export function resolveEffectiveCredential(options: {
requestApiKey: string;
requestApiBaseUrl: string;
legacyBaseUrl?: string;
+ legacyUpstreamProxyUrl?: string;
selectedCredential?: ChannelCredential;
}): EffectiveCredential {
if (options.requestApiKey) {
@@ -1094,6 +1108,7 @@ export function resolveEffectiveCredential(options: {
return {
apiKey: options.requestApiKey,
baseUrl: options.requestApiBaseUrl || normalizeOptionalString(options.legacyBaseUrl),
+ ...(options.legacyUpstreamProxyUrl ? { upstreamProxyUrl: options.legacyUpstreamProxyUrl } : {}),
upstreamProfile: requestProfile.id
};
}
@@ -1101,6 +1116,9 @@ export function resolveEffectiveCredential(options: {
return {
apiKey: options.selectedCredential?.apiKey,
baseUrl: options.selectedCredential?.baseUrl,
+ ...(options.selectedCredential?.upstreamProxyUrl
+ ? { upstreamProxyUrl: options.selectedCredential.upstreamProxyUrl }
+ : {}),
upstreamProfile: options.selectedCredential?.upstreamProfile || DEFAULT_EFFECTIVE_PROFILE_ID,
...(options.selectedCredential?.providerProfile
? { providerProfile: options.selectedCredential.providerProfile }
@@ -1194,7 +1212,10 @@ export function toPublicChannelFailure(
};
}
-function parseLegacyConfig(env: Record): ChannelPoolConfig {
+function parseLegacyConfig(
+ env: Record,
+ globalUpstreamProxyUrl: string | undefined
+): ChannelPoolConfig {
const apiKey = env.OPENAI_API_KEY?.trim();
const baseUrl = normalizeOptionalString(env.OPENAI_API_BASE_URL);
@@ -1227,6 +1248,7 @@ function parseLegacyConfig(env: Record): ChannelPool
channelId: 'default',
apiKey,
baseUrl,
+ ...(globalUpstreamProxyUrl ? { upstreamProxyUrl: globalUpstreamProxyUrl } : {}),
upstreamProfile: readImageUpstreamProfile({
explicitProfile: rawProfile,
channelId: 'default',
@@ -1242,11 +1264,14 @@ function parseLegacyConfig(env: Record): ChannelPool
function parseNumberedChannel(
env: Record,
channelIndex: number,
- globalRequestModePriority?: ChannelRequestMode[]
+ globalRequestModePriority?: ChannelRequestMode[],
+ globalUpstreamProxyUrl?: string
): ChannelCredential[] {
const channelId = readOptionalEnv(env, `OPENAI_CHANNEL_${channelIndex}_ID`) || `channel-${channelIndex}`;
const rawApiKeys = readRequiredEnv(env, `OPENAI_CHANNEL_${channelIndex}_API_KEYS`);
const baseUrl = normalizeOptionalString(env[`OPENAI_CHANNEL_${channelIndex}_BASE_URL`]);
+ const upstreamProxyUrl =
+ readOpenAIUpstreamProxyUrl(env, `OPENAI_CHANNEL_${channelIndex}_PROXY_URL`) ?? globalUpstreamProxyUrl;
const upstreamProfile = readChannelProfile(env, channelIndex, channelId, baseUrl);
const upstreamHeaders = readChannelUpstreamHeaders(env, channelIndex, upstreamProfile);
const providerManifest = readChannelProviderManifest(env, channelIndex, upstreamProfile);
@@ -1285,6 +1310,7 @@ function parseNumberedChannel(
channelId,
apiKey,
baseUrl,
+ ...(upstreamProxyUrl ? { upstreamProxyUrl } : {}),
upstreamProfile,
...(upstreamHeaders ? { upstreamHeaders } : {}),
...(providerManifest ? { providerManifest: createProviderManifestSummary(providerManifest) } : {}),
diff --git a/src/lib/image-route-mode-handlers.ts b/src/lib/image-route-mode-handlers.ts
index f07949a2710517781ff41d384121944cd5e10e10..c747b4f82cbb4ae85475097881cddbbe6c31d3ca 100644
--- a/src/lib/image-route-mode-handlers.ts
+++ b/src/lib/image-route-mode-handlers.ts
@@ -62,6 +62,7 @@ type CommonModeInput = {
storageMode: StorageMode;
apiBaseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
startedAtMs: number;
upstreamIdempotencyKey?: string;
clientRequestId?: string;
@@ -330,6 +331,7 @@ async function createResponsesImageStreamResponse(
storageMode: input.storageMode,
apiBaseUrl: input.apiBaseUrl,
apiKey: input.apiKey,
+ upstreamProxyUrl: input.upstreamProxyUrl,
upstreamHeaders: input.upstreamHeaders,
model: input.model,
startedAtMs: input.startedAtMs,
@@ -372,6 +374,7 @@ async function createGenerateStreamResponse(
stream = await createImagesApiGenerateStream({
apiBaseUrl: input.apiBaseUrl,
apiKey: input.apiKey,
+ upstreamProxyUrl: input.upstreamProxyUrl,
upstreamHeaders: input.upstreamHeaders,
idempotencyKey: input.upstreamIdempotencyKey,
abortSignal: input.abortSignal,
@@ -389,6 +392,7 @@ async function createGenerateStreamResponse(
storageMode: input.storageMode,
apiBaseUrl: input.apiBaseUrl,
apiKey: input.apiKey,
+ upstreamProxyUrl: input.upstreamProxyUrl,
upstreamHeaders: input.upstreamHeaders,
model: input.model,
startedAtMs: input.startedAtMs,
@@ -532,6 +536,7 @@ async function createEditStreamResponse(input: CommonModeInput, options: EditOpt
storageMode: input.storageMode,
apiBaseUrl: input.apiBaseUrl,
apiKey: input.apiKey,
+ upstreamProxyUrl: input.upstreamProxyUrl,
upstreamHeaders: input.upstreamHeaders,
model: input.model,
startedAtMs: input.startedAtMs,
@@ -596,6 +601,7 @@ export async function handleEditImageMode(
storageMode: input.storageMode,
apiBaseUrl: input.apiBaseUrl,
apiKey: input.apiKey,
+ upstreamProxyUrl: input.upstreamProxyUrl,
upstreamHeaders: input.upstreamHeaders,
model: input.model,
startedAtMs: input.startedAtMs,
diff --git a/src/lib/image-route-support.ts b/src/lib/image-route-support.ts
index 639cda35732bb168ca69b926049e623f04bf11b0..f83b6a6783f7e917b4ed83280c929a574c6faabb 100644
--- a/src/lib/image-route-support.ts
+++ b/src/lib/image-route-support.ts
@@ -437,6 +437,7 @@ export async function ensureOutputDirExists() {
async function resolveRequestActualCost(input: {
apiBaseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
model: string;
startedAtMs: number;
expectedImageCount: number;
@@ -453,6 +454,7 @@ async function resolveRequestActualCost(input: {
return resolveActualCost({
apiBaseUrl: input.apiBaseUrl,
apiKey: input.apiKey,
+ ...(input.upstreamProxyUrl ? { upstreamProxyUrl: input.upstreamProxyUrl } : {}),
model: input.model,
startedAtMs: input.startedAtMs,
finishedAtMs,
@@ -463,6 +465,7 @@ async function resolveRequestActualCost(input: {
export async function resolveRequestActualCostSafely(input: {
apiBaseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
model: string;
startedAtMs: number;
expectedImageCount: number;
diff --git a/src/lib/image-service.ts b/src/lib/image-service.ts
index a38a279b4d82c1ba81af39aedb29e7a5c237f11a..b3efdb975b963edcc2af06c2e160d6e1041b7e67 100644
--- a/src/lib/image-service.ts
+++ b/src/lib/image-service.ts
@@ -170,6 +170,7 @@ export async function persistOpenAiImages(options: {
batchId?: string;
apiBaseUrl?: string;
apiKey?: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
abortSignal?: AbortSignal;
}): Promise {
@@ -191,6 +192,7 @@ export async function persistOpenAiImages(options: {
imageUrl: imageData.url,
apiBaseUrl: options.apiBaseUrl,
apiKey: options.apiKey,
+ upstreamProxyUrl: options.upstreamProxyUrl,
upstreamHeaders: options.upstreamHeaders,
abortSignal: options.abortSignal
})
diff --git a/src/lib/image-stream-collector.ts b/src/lib/image-stream-collector.ts
index f3f26140037d6202b2a006b7fd4e94ec37feebf3..b8eb851f796c41dfff8d4390cd672e848f4cb082 100644
--- a/src/lib/image-stream-collector.ts
+++ b/src/lib/image-stream-collector.ts
@@ -39,6 +39,7 @@ export async function collectOpenAiImagesFromStream(
options: {
apiBaseUrl?: string;
apiKey?: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
abortSignal?: AbortSignal;
onStreamingDegraded?: (reason: string) => void;
@@ -83,6 +84,7 @@ export async function collectOpenAiImagesFromStream(
imageUrl: normalizedEvent.imageUrl,
apiBaseUrl: options.apiBaseUrl,
apiKey: options.apiKey,
+ upstreamProxyUrl: options.upstreamProxyUrl,
upstreamHeaders: options.upstreamHeaders,
abortSignal: options.abortSignal
})
diff --git a/src/lib/image-stream-service.ts b/src/lib/image-stream-service.ts
index d574f49871ce834c6c6375a5a7571a60def28efd..776ba51986cdb0a413ce9a8ce5b995d9f2d2952f 100644
--- a/src/lib/image-stream-service.ts
+++ b/src/lib/image-stream-service.ts
@@ -49,6 +49,7 @@ type SseWriter = ReturnType;
type ResolveStreamCostInput = {
apiBaseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
model: string;
startedAtMs: number;
@@ -63,6 +64,7 @@ export type ImageStreamResponseOptions = {
storageMode: StorageMode;
apiBaseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
model: string;
startedAtMs: number;
@@ -218,6 +220,7 @@ async function downloadOptionalPartialImage(runtime: StreamRuntime, imageUrl: st
imageUrl,
apiBaseUrl: runtime.options.apiBaseUrl,
apiKey: runtime.options.apiKey,
+ upstreamProxyUrl: runtime.options.upstreamProxyUrl,
upstreamHeaders: runtime.options.upstreamHeaders,
abortSignal: runtime.options.abortSignal
});
@@ -257,6 +260,7 @@ async function emitCompletedImage(
imageUrl: normalizedEvent.imageUrl,
apiBaseUrl: runtime.options.apiBaseUrl,
apiKey: runtime.options.apiKey,
+ upstreamProxyUrl: runtime.options.upstreamProxyUrl,
upstreamHeaders: runtime.options.upstreamHeaders,
abortSignal: runtime.options.abortSignal
})
@@ -368,6 +372,7 @@ async function emitFallbackImages(runtime: StreamRuntime, result: OpenAI.Images.
imageUrl: image.url,
apiBaseUrl: runtime.options.apiBaseUrl,
apiKey: runtime.options.apiKey,
+ upstreamProxyUrl: runtime.options.upstreamProxyUrl,
upstreamHeaders: runtime.options.upstreamHeaders,
abortSignal: runtime.options.abortSignal
})
@@ -396,6 +401,7 @@ async function emitDoneEvent(runtime: StreamRuntime): Promise {
const actualCost = await runtime.options.resolveActualCost({
apiBaseUrl: runtime.options.apiBaseUrl,
apiKey: runtime.options.apiKey,
+ upstreamProxyUrl: runtime.options.upstreamProxyUrl,
model: runtime.options.model,
startedAtMs: runtime.options.startedAtMs,
expectedImageCount: runtime.state.completedImages.length,
diff --git a/src/lib/image-url-result.test.ts b/src/lib/image-url-result.test.ts
index c176f45c07de97ca038bc88b1191e6dfba178b72..b8021b9d2646f12bd0dfe601e56114a22926196c 100644
--- a/src/lib/image-url-result.test.ts
+++ b/src/lib/image-url-result.test.ts
@@ -44,6 +44,35 @@ describe('downloadSameOriginImageAsBase64', () => {
assert.equal(observedAppSecret, 'app-secret');
});
+ it('uses the shared header policy when the image download has no API key', async () => {
+ let observedProxyAuthorization: string | null = null;
+ let observedUserAgent: string | null = null;
+ let observedAppId: string | null = null;
+ globalThis.fetch = async (_url, init) => {
+ const headers = new Headers(init?.headers);
+ observedProxyAuthorization = headers.get('proxy-authorization');
+ observedUserAgent = headers.get('user-agent');
+ observedAppId = headers.get('x-app-id');
+ return new Response(Buffer.from('png'), {
+ status: 200,
+ headers: { 'content-type': 'image/png' }
+ });
+ };
+
+ await downloadSameOriginImageAsBase64({
+ imageUrl: '/generated/final.png',
+ apiBaseUrl: 'https://api.example.test/v1',
+ upstreamHeaders: {
+ 'Proxy-Authorization': 'Basic c2VjcmV0',
+ 'X-App-ID': 'app-id'
+ }
+ });
+
+ assert.equal(observedProxyAuthorization, null);
+ assert.equal(observedUserAgent, 'gpt-image-playground/2.1.0');
+ assert.equal(observedAppId, 'app-id');
+ });
+
it('enforces the remote image size limit when fetch returns no stream body', async () => {
let arrayBufferRead = false;
globalThis.fetch = async () =>
diff --git a/src/lib/image-url-result.ts b/src/lib/image-url-result.ts
index a8dc42f6536a52bf71dd611fa3b748f0baaa4b19..3ee0cce80d3f7daf65d02c4f9244fc13d0e4081a 100644
--- a/src/lib/image-url-result.ts
+++ b/src/lib/image-url-result.ts
@@ -1,5 +1,6 @@
import { RequestValidationError } from './image-request-utils';
import { mergeUpstreamHeadersWithFixed, type UpstreamRequestHeaders } from './image-upstream-profile';
+import { fetchOpenAIUpstream } from './openai-image-transport';
const MAX_REMOTE_IMAGE_BYTES = 25 * 1024 * 1024;
const REMOTE_IMAGE_DOWNLOAD_TIMEOUT_MS = 30000;
@@ -41,6 +42,7 @@ export async function downloadSameOriginImageAsBase64(input: {
imageUrl: string;
apiBaseUrl?: string;
apiKey?: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
abortSignal?: AbortSignal;
}): Promise {
@@ -50,10 +52,14 @@ export async function downloadSameOriginImageAsBase64(input: {
const abortListener = () => controller.abort();
input.abortSignal?.addEventListener('abort', abortListener, { once: true });
try {
- const response = await fetch(url, {
- headers: buildDownloadHeaders(input.apiKey, input.upstreamHeaders),
- signal: controller.signal
- });
+ const response = await fetchOpenAIUpstream(
+ url,
+ {
+ headers: buildDownloadHeaders(input.apiKey, input.upstreamHeaders),
+ signal: controller.signal
+ },
+ input.upstreamProxyUrl
+ );
if (!response.ok) {
throw new RemoteImageResultError(`下载上游图片失败:HTTP ${response.status}。`);
}
@@ -86,9 +92,10 @@ function buildDownloadHeaders(
apiKey: string | undefined,
upstreamHeaders: UpstreamRequestHeaders | undefined
): UpstreamRequestHeaders | undefined {
- const headers = apiKey
- ? mergeUpstreamHeadersWithFixed(upstreamHeaders, { Authorization: `Bearer ${apiKey}` })
- : { ...(upstreamHeaders || {}) };
+ const headers = mergeUpstreamHeadersWithFixed(
+ upstreamHeaders,
+ apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
+ );
return Object.keys(headers).length > 0 ? headers : undefined;
}
diff --git a/src/lib/images-api-stream.ts b/src/lib/images-api-stream.ts
index 10e2c64614d46a93429baf207918d112a71f676e..bc427990dd4649387c03bb5365fc04f8ca82a773 100644
--- a/src/lib/images-api-stream.ts
+++ b/src/lib/images-api-stream.ts
@@ -1,5 +1,5 @@
import { mergeUpstreamHeadersWithFixed, type UpstreamRequestHeaders } from './image-upstream-profile';
-import { readImageUpstreamTimeoutMs } from './openai-image-transport';
+import { fetchOpenAIUpstream, readImageUpstreamTimeoutMs } from './openai-image-transport';
import type OpenAI from 'openai';
export class ImagesApiStreamError extends Error {
@@ -15,6 +15,7 @@ export class ImagesApiStreamError extends Error {
type ImagesApiStreamInput = {
apiBaseUrl?: string;
apiKey: string;
+ upstreamProxyUrl?: string;
upstreamHeaders?: UpstreamRequestHeaders;
idempotencyKey?: string;
abortSignal?: AbortSignal;
@@ -133,21 +134,25 @@ function readSseChunk(chunk: string): unknown | undefined {
}
export async function createImagesApiGenerateStream(input: ImagesApiStreamInput): Promise> {
- const { abortSignal, apiBaseUrl, apiKey, idempotencyKey, params, upstreamHeaders } = input;
+ const { abortSignal, apiBaseUrl, apiKey, idempotencyKey, params, upstreamHeaders, upstreamProxyUrl } = input;
const abortContext = createAbortContext({ abortSignal, timeoutMs: input.timeoutMs });
let response: Response;
try {
- response = await fetch(buildImagesGenerateUrl(apiBaseUrl), {
- method: 'POST',
- headers: mergeUpstreamHeadersWithFixed(upstreamHeaders, {
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json',
- Accept: 'text/event-stream, application/json',
- ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
- }),
- signal: abortContext.signal,
- body: JSON.stringify(params)
- });
+ response = await fetchOpenAIUpstream(
+ buildImagesGenerateUrl(apiBaseUrl),
+ {
+ method: 'POST',
+ headers: mergeUpstreamHeadersWithFixed(upstreamHeaders, {
+ Authorization: `Bearer ${apiKey}`,
+ 'Content-Type': 'application/json',
+ Accept: 'text/event-stream, application/json',
+ ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {})
+ }),
+ signal: abortContext.signal,
+ body: JSON.stringify(params)
+ },
+ upstreamProxyUrl
+ );
} catch (error) {
abortContext.cleanup();
throw error;
diff --git a/src/lib/openai-image-transport.test.ts b/src/lib/openai-image-transport.test.ts
index e582e81bc752d378dcb7d23671b9465ae0133369..091ace4072854812bdb14e50ffe2554f6d3c1d58 100644
--- a/src/lib/openai-image-transport.test.ts
+++ b/src/lib/openai-image-transport.test.ts
@@ -1,13 +1,19 @@
import {
buildOpenAIImageRequestOptions,
createOpenAIImageClientOptions,
+ fetchOpenAIUpstream,
readImageStreamDataIntervalTimeoutMs,
readImageUpstreamMaxRetries,
readImageUpstreamTimeoutMs,
+ readOpenAIUpstreamProxyUrl,
+ summarizeOpenAIUpstreamProxy,
summarizeOpenAIImageTransport
} from './openai-image-transport';
import assert from 'node:assert/strict';
+import http from 'node:http';
+import net from 'node:net';
import { describe, it } from 'node:test';
+import OpenAI from 'openai';
describe('openai image transport settings', () => {
it('uses long image defaults and disables automatic SDK retries', () => {
@@ -17,7 +23,8 @@ describe('openai image transport settings', () => {
assert.deepEqual(summarizeOpenAIImageTransport({}), {
upstream_timeout_ms: 900_000,
stream_data_interval_timeout_ms: 900_000,
- upstream_max_retries: 0
+ upstream_max_retries: 0,
+ upstream_proxy: { configured: false }
});
assert.deepEqual(createOpenAIImageClientOptions({ apiKey: 'key', baseURL: 'https://api.example/v1' }), {
@@ -61,4 +68,187 @@ describe('openai image transport settings', () => {
assert.equal(readImageStreamDataIntervalTimeoutMs({ IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS: '0' }), 0);
assert.throws(() => readImageUpstreamMaxRetries({ IMAGE_UPSTREAM_MAX_RETRIES: '-1' }), /非负整数/);
});
+
+ it('accepts only bare HTTP(S) proxy URLs and redacts the configured endpoint', () => {
+ const proxyUrl = readOpenAIUpstreamProxyUrl({
+ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.internal.example:9443'
+ });
+
+ assert.equal(proxyUrl, 'https://proxy.internal.example:9443/');
+ assert.deepEqual(summarizeOpenAIUpstreamProxy(proxyUrl), { configured: true, protocol: 'https' });
+ assert.equal(JSON.stringify(summarizeOpenAIUpstreamProxy(proxyUrl)).includes('proxy.internal.example'), false);
+ assert.equal(JSON.stringify(summarizeOpenAIUpstreamProxy(proxyUrl)).includes('9443'), false);
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'socks5://127.0.0.1:1080' }),
+ /不支持 SOCKS/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'http://proxy-user:secret@proxy.example' }),
+ /不能包含代理用户名或密码/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.example/path' }),
+ /不能包含路径/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.example?token=secret' }),
+ /不能包含查询参数/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.example/?' }),
+ /不能包含查询参数/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.example/#' }),
+ /不能包含查询参数/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.example/%2e' }),
+ /不能包含路径/
+ );
+ assert.throws(
+ () => readOpenAIUpstreamProxyUrl({ OPENAI_UPSTREAM_PROXY_URL: 'https://proxy.example\\path' }),
+ /不能包含路径/
+ );
+ });
+
+ it('routes SDK and native upstream fetches through an HTTP proxy', async () => {
+ const upstreamRequests: string[] = [];
+ const upstream = await startHttpServer((request, response) => {
+ upstreamRequests.push(request.url || '');
+ if (request.url === '/v1/models') {
+ response.writeHead(200, { 'Content-Type': 'application/json', Connection: 'close' });
+ response.end(JSON.stringify({ object: 'list', data: [] }));
+ return;
+ }
+ if (request.url === '/native') {
+ response.writeHead(200, { 'Content-Type': 'text/plain', Connection: 'close' });
+ response.end('native-through-proxy');
+ return;
+ }
+ response.writeHead(404, { 'Content-Type': 'application/json', Connection: 'close' });
+ response.end(JSON.stringify({ error: { message: 'not found' } }));
+ });
+ const proxy = await startHttpConnectProxy();
+
+ try {
+ const nativeResponse = await fetchOpenAIUpstream(
+ `${upstream.baseUrl}/native`,
+ { headers: { Connection: 'close' } },
+ proxy.url
+ );
+ assert.equal(await nativeResponse.text(), 'native-through-proxy');
+
+ const client = new OpenAI(
+ createOpenAIImageClientOptions({
+ apiKey: 'sk-proxy-test',
+ baseURL: `${upstream.baseUrl}/v1`,
+ upstreamProxyUrl: proxy.url,
+ defaultHeaders: { Connection: 'close' }
+ })
+ );
+ const models = await client.models.list();
+
+ assert.deepEqual(models.data, []);
+ assert.deepEqual(upstreamRequests, ['/native', '/v1/models']);
+ assert.equal(proxy.connectTargets.length, 2);
+ assert.ok(proxy.connectTargets.every((target) => target === upstream.origin));
+ } finally {
+ await proxy.close();
+ await upstream.close();
+ }
+ });
});
+
+async function startHttpServer(
+ handler: (request: http.IncomingMessage, response: http.ServerResponse) => void
+): Promise<{ baseUrl: string; origin: string; close: () => Promise }> {
+ const sockets = new Set();
+ const server = http.createServer(handler);
+ server.on('connection', (socket) => {
+ sockets.add(socket);
+ socket.on('close', () => sockets.delete(socket));
+ });
+ await listen(server);
+ const address = server.address();
+ assert.ok(address && typeof address === 'object');
+ const origin = `127.0.0.1:${address.port}`;
+ return {
+ baseUrl: `http://${origin}`,
+ origin,
+ close: () => closeServer(server, sockets)
+ };
+}
+
+async function startHttpConnectProxy(): Promise<{
+ url: string;
+ connectTargets: string[];
+ close: () => Promise;
+}> {
+ const sockets = new Set();
+ const connectTargets: string[] = [];
+ const server = http.createServer((_request, response) => {
+ response.writeHead(405, { Connection: 'close' });
+ response.end();
+ });
+ server.on('connection', (socket) => {
+ sockets.add(socket);
+ socket.on('close', () => sockets.delete(socket));
+ });
+ server.on('connect', (request, clientSocket, head) => {
+ const target = readConnectTarget(request.url);
+ if (!target) {
+ clientSocket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
+ return;
+ }
+ connectTargets.push(`${target.hostname}:${target.port}`);
+ const targetSocket = net.connect({ host: target.hostname, port: Number(target.port) });
+ sockets.add(targetSocket);
+ targetSocket.on('close', () => sockets.delete(targetSocket));
+ targetSocket.once('connect', () => {
+ clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n');
+ if (head.length > 0) targetSocket.write(head);
+ clientSocket.pipe(targetSocket);
+ targetSocket.pipe(clientSocket);
+ });
+ targetSocket.once('error', () => {
+ if (!clientSocket.destroyed) {
+ clientSocket.end('HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n');
+ }
+ });
+ });
+ await listen(server);
+ const address = server.address();
+ assert.ok(address && typeof address === 'object');
+ return {
+ url: `http://127.0.0.1:${address.port}`,
+ connectTargets,
+ close: () => closeServer(server, sockets)
+ };
+}
+
+function readConnectTarget(rawTarget: string | undefined): URL | undefined {
+ if (!rawTarget) return undefined;
+ try {
+ return new URL(`http://${rawTarget}`);
+ } catch {
+ return undefined;
+ }
+}
+
+async function listen(server: http.Server): Promise {
+ await new Promise((resolve, reject) => {
+ server.once('error', reject);
+ server.listen(0, '127.0.0.1', resolve);
+ });
+}
+
+async function closeServer(server: http.Server, sockets: Set): Promise {
+ for (const socket of sockets) socket.destroy();
+ await new Promise((resolve, reject) => {
+ server.close((error) => {
+ if (error) reject(error);
+ else resolve();
+ });
+ });
+}
diff --git a/src/lib/openai-image-transport.ts b/src/lib/openai-image-transport.ts
index 9baf4cdc4b6f3ed3633d5428a95bf4f2887b3ca9..7f9c4009a5189a726057a30d55b46488474eae43 100644
--- a/src/lib/openai-image-transport.ts
+++ b/src/lib/openai-image-transport.ts
@@ -1,11 +1,21 @@
import type OpenAI from 'openai';
import type { ClientOptions } from 'openai';
+import { fetch as undiciFetch, ProxyAgent } from 'undici';
type ImageTransportEnv = Record;
+type UpstreamProxyProtocol = 'http' | 'https';
+
+export type UpstreamProxySummary = {
+ configured: boolean;
+ protocol?: UpstreamProxyProtocol;
+};
+
const DEFAULT_IMAGE_UPSTREAM_TIMEOUT_MS = 900_000;
const DEFAULT_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS = 900_000;
const DEFAULT_IMAGE_UPSTREAM_MAX_RETRIES = 0;
+const UPSTREAM_PROXY_URL_ENV = 'OPENAI_UPSTREAM_PROXY_URL';
+const proxyDispatcherByUrl = new Map();
export function readImageUpstreamTimeoutMs(env: ImageTransportEnv = process.env): number {
return readPositiveIntegerEnv(env, 'IMAGE_UPSTREAM_TIMEOUT_MS', DEFAULT_IMAGE_UPSTREAM_TIMEOUT_MS);
@@ -27,17 +37,55 @@ export function createOpenAIImageClientOptions(input: {
apiKey: string;
baseURL?: string;
defaultHeaders?: ClientOptions['defaultHeaders'];
+ upstreamProxyUrl?: string;
env?: ImageTransportEnv;
}): ClientOptions {
+ const upstreamProxyUrl = input.upstreamProxyUrl
+ ? normalizeUpstreamProxyUrl(input.upstreamProxyUrl, UPSTREAM_PROXY_URL_ENV)
+ : undefined;
return {
apiKey: input.apiKey,
baseURL: input.baseURL,
defaultHeaders: input.defaultHeaders,
+ ...(upstreamProxyUrl ? { fetch: createOpenAIUpstreamFetch(upstreamProxyUrl) } : {}),
timeout: readImageUpstreamTimeoutMs(input.env),
maxRetries: readImageUpstreamMaxRetries(input.env)
};
}
+export function readOpenAIUpstreamProxyUrl(
+ env: ImageTransportEnv = process.env,
+ fieldName = UPSTREAM_PROXY_URL_ENV
+): string | undefined {
+ const rawValue = env[fieldName]?.trim();
+ return rawValue ? normalizeUpstreamProxyUrl(rawValue, fieldName) : undefined;
+}
+
+export function summarizeOpenAIUpstreamProxy(upstreamProxyUrl: string | undefined): UpstreamProxySummary {
+ if (!upstreamProxyUrl) return { configured: false };
+ const parsed = new URL(normalizeUpstreamProxyUrl(upstreamProxyUrl, UPSTREAM_PROXY_URL_ENV));
+ return {
+ configured: true,
+ protocol: parsed.protocol.slice(0, -1) as UpstreamProxyProtocol
+ };
+}
+
+export function fetchOpenAIUpstream(
+ input: Parameters>[0],
+ init: Parameters>[1] | undefined,
+ upstreamProxyUrl?: string
+): Promise {
+ if (!upstreamProxyUrl) {
+ return fetch(input, init);
+ }
+ const normalizedProxyUrl = normalizeUpstreamProxyUrl(upstreamProxyUrl, UPSTREAM_PROXY_URL_ENV);
+ const dispatcher = getUpstreamProxyDispatcher(normalizedProxyUrl);
+ return undiciFetch(
+ input as Parameters[0],
+ { ...(init || {}), dispatcher } as Parameters[1]
+ ) as unknown as Promise;
+}
+
export function buildOpenAIImageRequestOptions(
input: {
abortSignal?: AbortSignal;
@@ -60,10 +108,58 @@ export function summarizeOpenAIImageTransport(env: ImageTransportEnv = process.e
return {
upstream_timeout_ms: readImageUpstreamTimeoutMs(env),
stream_data_interval_timeout_ms: readImageStreamDataIntervalTimeoutMs(env),
- upstream_max_retries: readImageUpstreamMaxRetries(env)
+ upstream_max_retries: readImageUpstreamMaxRetries(env),
+ upstream_proxy: summarizeOpenAIUpstreamProxy(readOpenAIUpstreamProxyUrl(env))
};
}
+function createOpenAIUpstreamFetch(upstreamProxyUrl: string): NonNullable {
+ return (input, init) => fetchOpenAIUpstream(input, init, upstreamProxyUrl);
+}
+
+function getUpstreamProxyDispatcher(upstreamProxyUrl: string): ProxyAgent {
+ const existing = proxyDispatcherByUrl.get(upstreamProxyUrl);
+ if (existing) return existing;
+ const dispatcher = new ProxyAgent(upstreamProxyUrl);
+ proxyDispatcherByUrl.set(upstreamProxyUrl, dispatcher);
+ return dispatcher;
+}
+
+function normalizeUpstreamProxyUrl(rawValue: string, fieldName: string): string {
+ // URL normalizes empty query strings, fragments, and dot paths away, so inspect the source before parsing.
+ if (rawValue.includes('?') || rawValue.includes('#')) {
+ throw new Error(`${fieldName} 不能包含查询参数或片段。`);
+ }
+ const protocolMatch = /^([a-z][a-z0-9+.-]*):\/\//i.exec(rawValue);
+ if (!protocolMatch) {
+ throw new Error(`${fieldName} 必须是有效的 HTTP 或 HTTPS 代理 URL。`);
+ }
+ if (protocolMatch[1].toLowerCase() !== 'http' && protocolMatch[1].toLowerCase() !== 'https') {
+ throw new Error(`${fieldName} 仅支持 http 或 https 代理,不支持 SOCKS 或其他协议。`);
+ }
+ const authorityAndPath = rawValue.slice(protocolMatch[0].length);
+ const pathStartIndex = authorityAndPath.search(/[\\/]/);
+ if (pathStartIndex >= 0 && authorityAndPath.slice(pathStartIndex) !== '/') {
+ throw new Error(`${fieldName} 不能包含路径。`);
+ }
+ let parsed: URL;
+ try {
+ parsed = new URL(rawValue);
+ } catch {
+ throw new Error(`${fieldName} 必须是有效的 HTTP 或 HTTPS 代理 URL。`);
+ }
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ throw new Error(`${fieldName} 仅支持 http 或 https 代理,不支持 SOCKS。`);
+ }
+ if (!parsed.hostname) {
+ throw new Error(`${fieldName} 必须包含代理服务器主机名。`);
+ }
+ if (parsed.username || parsed.password) {
+ throw new Error(`${fieldName} 不能包含代理用户名或密码。`);
+ }
+ return parsed.toString();
+}
+
function readNonNegativeIntegerEnv(env: ImageTransportEnv, fieldName: string, fallback: number): number {
const rawValue = env[fieldName];
if (rawValue === undefined || rawValue.trim() === '') return fallback;
diff --git a/src/lib/upstream-cost/new-api.ts b/src/lib/upstream-cost/new-api.ts
index 7d0393d3b78330af346e6b4890abdc837901490e..0e70eb71a3aaa2f083f3a2f86565ac02530264ef 100644
--- a/src/lib/upstream-cost/new-api.ts
+++ b/src/lib/upstream-cost/new-api.ts
@@ -1,4 +1,5 @@
import type { ActualCostDetails, ActualCostResolver, ResolveActualCostInput } from './types';
+import { fetchOpenAIUpstream } from '../openai-image-transport';
export const NEW_API_QUOTA_PER_UNIT = 500_000;
@@ -113,17 +114,21 @@ export function matchNewApiCostLog(input: {
};
}
-async function fetchLogs(url: URL, apiKey: string): Promise {
+async function fetchLogs(url: URL, apiKey: string, upstreamProxyUrl?: string): Promise {
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), FETCH_TIMEOUT_MS);
try {
- const response = await fetch(url, {
- headers: {
- Authorization: `Bearer ${apiKey}`,
- Accept: 'application/json'
+ const response = await fetchOpenAIUpstream(
+ url,
+ {
+ headers: {
+ Authorization: `Bearer ${apiKey}`,
+ Accept: 'application/json'
+ },
+ signal: abortController.signal
},
- signal: abortController.signal
- });
+ upstreamProxyUrl
+ );
if (!response.ok) return undefined;
const body = (await response.json()) as NewApiLogResponse;
@@ -149,7 +154,7 @@ export class NewApiCostResolver implements ActualCostResolver {
try {
for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
- const logs = await fetchLogs(url, input.apiKey);
+ const logs = await fetchLogs(url, input.apiKey, input.upstreamProxyUrl);
if (logs) {
const result = matchNewApiCostLog({
logs,
diff --git a/src/lib/upstream-cost/types.ts b/src/lib/upstream-cost/types.ts
index a414fd43bf23aa79c9846cf32150e997d878fff7..6f8321ee2d31b272d3aaba47991442603cc71b8d 100644
--- a/src/lib/upstream-cost/types.ts
+++ b/src/lib/upstream-cost/types.ts
@@ -27,7 +27,9 @@ export type ActualCostDetails = {
reason?: string;
};
-type UpstreamCostCredentials = { apiBaseUrl: string; apiKey: string } | { apiBaseUrl?: undefined; apiKey?: undefined };
+type UpstreamCostCredentials =
+ | { apiBaseUrl: string; apiKey: string; upstreamProxyUrl?: string }
+ | { apiBaseUrl?: undefined; apiKey?: undefined; upstreamProxyUrl?: undefined };
export type ResolveActualCostInput = UpstreamCostCredentials & {
model: string;
diff --git a/src/lib/upstream-request-headers.test.ts b/src/lib/upstream-request-headers.test.ts
index 6ca9b1c18f163b5259104fbd719edabc4193fcac..f81128288dc8b7217ffe4692e936c0694363de94 100644
--- a/src/lib/upstream-request-headers.test.ts
+++ b/src/lib/upstream-request-headers.test.ts
@@ -44,6 +44,16 @@ describe('upstream request headers', () => {
);
});
+ it('filters proxy authentication from extra headers before dispatch', () => {
+ const merged = mergeUpstreamHeadersWithFixed(
+ { 'Proxy-Authorization': 'Basic c2VjcmV0' },
+ {},
+ {}
+ );
+
+ assert.equal(new Headers(merged).has('proxy-authorization'), false);
+ });
+
it('rejects unsafe configurable protocol headers', () => {
assert.throws(
() =>
@@ -69,6 +79,14 @@ describe('upstream request headers', () => {
),
/不能配置 Idempotency-Key/
);
+ assert.throws(
+ () =>
+ normalizeConfiguredUpstreamHeaders(
+ { 'Proxy-Authorization': 'Basic c2VjcmV0' },
+ 'OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON'
+ ),
+ /不能配置 Proxy-Authorization/
+ );
});
it('summarizes request headers without exposing secret values', () => {
diff --git a/src/lib/upstream-request-headers.ts b/src/lib/upstream-request-headers.ts
index d2af9ce1f2926e9b6ecd9f815f8f24e583f3e9c4..2d9030b620713ca248b0ae6631fc9baa53b20a2d 100644
--- a/src/lib/upstream-request-headers.ts
+++ b/src/lib/upstream-request-headers.ts
@@ -16,10 +16,11 @@ const CONFIGURABLE_HEADER_BLOCKLIST = new Set([
'content-type',
'content-length',
'host',
- 'idempotency-key'
+ 'idempotency-key',
+ 'proxy-authorization'
]);
-// Defense-in-depth: fixed idempotency headers must never leak from extra upstream headers.
-const ALWAYS_FILTERED_EXTRA_HEADER_NAMES = new Set(['idempotency-key']);
+// Defense-in-depth: protocol and proxy-auth headers must never leak from extra upstream headers.
+const ALWAYS_FILTERED_EXTRA_HEADER_NAMES = new Set(['idempotency-key', 'proxy-authorization']);
const CANONICAL_HEADER_NAMES: Record = {
accept: 'Accept',
authorization: 'Authorization',
diff --git a/vendor/brace-expansion-compat/index.cjs b/vendor/brace-expansion-compat/index.cjs
new file mode 100644
index 0000000000000000000000000000000000000000..a60c7246eabcf0bbfc9e048f2ad29561140acdc2
--- /dev/null
+++ b/vendor/brace-expansion-compat/index.cjs
@@ -0,0 +1,9 @@
+'use strict';
+
+const modern = require('brace-expansion-modern');
+const expand = modern.expand;
+
+module.exports = expand;
+module.exports.expand = expand;
+module.exports.EXPANSION_MAX = modern.EXPANSION_MAX;
+module.exports.EXPANSION_MAX_LENGTH = modern.EXPANSION_MAX_LENGTH;
diff --git a/vendor/brace-expansion-compat/index.d.ts b/vendor/brace-expansion-compat/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..70bb52f39e9575f6fcad4ef566b85fb2178da336
--- /dev/null
+++ b/vendor/brace-expansion-compat/index.d.ts
@@ -0,0 +1,9 @@
+export type BraceExpansionOptions = {
+ max?: number;
+ maxLength?: number;
+};
+
+export declare const EXPANSION_MAX: number;
+export declare const EXPANSION_MAX_LENGTH: number;
+export declare function expand(str: string, options?: BraceExpansionOptions): string[];
+export default expand;
diff --git a/vendor/brace-expansion-compat/index.mjs b/vendor/brace-expansion-compat/index.mjs
new file mode 100644
index 0000000000000000000000000000000000000000..2ed3aa1df64f7c48090a4ff67104fa3941736d22
--- /dev/null
+++ b/vendor/brace-expansion-compat/index.mjs
@@ -0,0 +1,4 @@
+import { EXPANSION_MAX, EXPANSION_MAX_LENGTH, expand } from 'brace-expansion-modern';
+
+export { EXPANSION_MAX, EXPANSION_MAX_LENGTH, expand };
+export default expand;
diff --git a/vendor/brace-expansion-compat/package.json b/vendor/brace-expansion-compat/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..8d3a910ea9b4fece4476172a85059b7390549142
--- /dev/null
+++ b/vendor/brace-expansion-compat/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "brace-expansion",
+ "version": "5.0.8",
+ "description": "Compatibility facade for brace-expansion 5.0.8",
+ "private": true,
+ "license": "MIT",
+ "type": "module",
+ "main": "./index.cjs",
+ "module": "./index.mjs",
+ "types": "./index.d.ts",
+ "exports": {
+ ".": {
+ "import": {
+ "types": "./index.d.ts",
+ "default": "./index.mjs"
+ },
+ "require": {
+ "types": "./index.d.ts",
+ "default": "./index.cjs"
+ }
+ },
+ "./package.json": "./package.json"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "dependencies": {
+ "brace-expansion-modern": "npm:brace-expansion@5.0.8"
+ }
+}