gpt-image-playground deploy commited on
Commit
a2c232b
·
1 Parent(s): e5a9556

Deploy 4770498 to Docker Space

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