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。

- GPT Image Playground 界面 + GPT Image Playground 界面

## 快速开始 @@ -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" + } +}