Spaces:
Running
Running
Deploy 34544cc to Docker Space
Browse filesSource: MisonL/gpt-image-playground-customer@34544ccd35f4321f92ab73ef5ac36603ee9f7720
- .env.example +13 -2
- README.md +8 -3
- public/hf-space-deploy-marker.json +3 -3
- scripts/agent-doctor.mjs +99 -2
- scripts/agent-skill-scripts.test.mjs +110 -5
- scripts/command-center.test.mjs +92 -2
- scripts/smoke-image-upstream-compat.mjs +1 -1
- scripts/smoke-image-upstream-real.mjs +8 -0
- scripts/status.mjs +42 -7
- skills/gpt-image-playground-agent/SKILL.md +17 -3
- skills/gpt-image-playground-agent/references/api.md +10 -4
- skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs +38 -1
- skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs +15 -0
- src/app/api/agent/agent-routes.test.ts +122 -0
- src/app/api/agent/capabilities/route.ts +1 -1
- src/app/api/deploy-marker/route.ts +1 -1
- src/app/api/images/route.test.ts +104 -32
- src/app/api/images/route.ts +116 -10
- src/app/api/runtime-capabilities/route.test.ts +134 -4
- src/app/api/runtime-capabilities/route.ts +24 -0
- src/lib/agent-api-contracts.test.ts +102 -0
- src/lib/agent-api-contracts.ts +35 -0
- src/lib/agent-channel-request-mode.ts +151 -0
- src/lib/agent-image-service.ts +141 -48
- src/lib/agent-openapi.ts +66 -2
- src/lib/api-error-response.test.ts +52 -4
- src/lib/api-error-response.ts +39 -1
- src/lib/channel-health-prober.ts +4 -1
- src/lib/channel-request-mode-values.mjs +36 -0
- src/lib/channel-request-mode.ts +130 -0
- src/lib/channel-router.test.ts +434 -0
- src/lib/channel-router.ts +332 -26
- src/lib/image-route-support.ts +16 -5
.env.example
CHANGED
|
@@ -18,6 +18,7 @@ OPENAI_API_BASE_URL=
|
|
| 18 |
# - round_robin:按请求顺序轮询所有渠道 key,适合简单均摊流量。
|
| 19 |
# - random:每次随机选择一个渠道 key,适合轻量分散请求。
|
| 20 |
# OPENAI_ROUTING_STRATEGY=sticky
|
|
|
|
| 21 |
#
|
| 22 |
# 渠道配置规则:
|
| 23 |
# - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。
|
|
@@ -26,6 +27,13 @@ OPENAI_API_BASE_URL=
|
|
| 26 |
# - BASE_URL 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。
|
| 27 |
# - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。
|
| 28 |
# - API_KEYS 支持一个或多个 key,多个 key 用英文逗号分隔。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
|
| 30 |
# - API Key 本身不要包含逗号。
|
| 31 |
#
|
|
@@ -33,6 +41,7 @@ OPENAI_API_BASE_URL=
|
|
| 33 |
# OPENAI_CHANNEL_1_ID=official
|
| 34 |
# OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
|
| 35 |
# OPENAI_CHANNEL_1_API_KEYS=sk-key-1,sk-key-2
|
|
|
|
| 36 |
# OPENAI_CHANNEL_1_FAILURE_COOLDOWN_MS=30000
|
| 37 |
# OPENAI_CHANNEL_1_USER_AGENT=gpt-image-playground/customer
|
| 38 |
# OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON={"X-Custom-Client":"customer"}
|
|
@@ -64,6 +73,7 @@ OPENAI_API_BASE_URL=
|
|
| 64 |
# 页面提供显式“并发批量”开关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。
|
| 65 |
# 默认 sticky 路由按单个 credential 容量推荐并发;round_robin/random 才会使用完整 credential 池。
|
| 66 |
# key 出现鉴权、额度或限流类错误后会短暂冷却;渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。
|
|
|
|
| 67 |
# OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
|
| 68 |
# OPENAI_CHANNEL_QUEUE_ENABLED=true
|
| 69 |
# OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS=420000
|
|
@@ -73,8 +83,9 @@ OPENAI_API_BASE_URL=
|
|
| 73 |
# OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=30000
|
| 74 |
#
|
| 75 |
# 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的
|
| 76 |
-
# credential/channel 先通过后台 GET /models 探测,成功后才重新进入用户生图流量。
|
| 77 |
-
# 探测不调用 /images/generations,不触发生图费用;
|
|
|
|
| 78 |
# OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true
|
| 79 |
# 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。
|
| 80 |
# OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true
|
|
|
|
| 18 |
# - round_robin:按请求顺序轮询所有渠道 key,适合简单均摊流量。
|
| 19 |
# - random:每次随机选择一个渠道 key,适合轻量分散请求。
|
| 20 |
# OPENAI_ROUTING_STRATEGY=sticky
|
| 21 |
+
# OPENAI_UPSTREAM_REQUEST_MODES=images-non-stream,images-sse,responses-non-stream,responses-sse
|
| 22 |
#
|
| 23 |
# 渠道配置规则:
|
| 24 |
# - N 从 1 开始递增,例如 OPENAI_CHANNEL_1_*、OPENAI_CHANNEL_2_*。
|
|
|
|
| 27 |
# - BASE_URL 默认要求 https;本机 loopback HTTP 可直接用于本地 fixture。
|
| 28 |
# - 远程 HTTP 必须加入 OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS。
|
| 29 |
# - API_KEYS 支持一个或多个 key,多个 key 用英文逗号分隔。
|
| 30 |
+
# - REQUEST_MODES 可选,用于声明该渠道经真实 smoke 验证可用的服务端请求方式。
|
| 31 |
+
# 单 key 旧配置可用 OPENAI_UPSTREAM_REQUEST_MODES 声明全局可用方式。
|
| 32 |
+
# 该白名单只供服务端路由和诊断使用;Agent 客户端仍只提交业务意图。
|
| 33 |
+
# stream_mode=auto 可由服务端显式 fallback;stream_mode=stream 或显式页面 SSE 不会静默降级。
|
| 34 |
+
# 可选值:images-non-stream、images-sse、responses-non-stream、responses-sse。
|
| 35 |
+
# 旧后端名别名仅用于兼容输入,建议新配置始终使用上面四个规范值。
|
| 36 |
+
# 未配置时默认认为四种方式都可尝试;配置后服务端只会把匹配方式的请求路由到该渠道。
|
| 37 |
# - FAILURE_COOLDOWN_MS 可选,覆盖该渠道失败后的冷却时间。
|
| 38 |
# - API Key 本身不要包含逗号。
|
| 39 |
#
|
|
|
|
| 41 |
# OPENAI_CHANNEL_1_ID=official
|
| 42 |
# OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
|
| 43 |
# OPENAI_CHANNEL_1_API_KEYS=sk-key-1,sk-key-2
|
| 44 |
+
# OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse
|
| 45 |
# OPENAI_CHANNEL_1_FAILURE_COOLDOWN_MS=30000
|
| 46 |
# OPENAI_CHANNEL_1_USER_AGENT=gpt-image-playground/customer
|
| 47 |
# OPENAI_CHANNEL_1_UPSTREAM_HEADERS_JSON={"X-Custom-Client":"customer"}
|
|
|
|
| 73 |
# 页面提供显式“并发批量”开关;开启后,流式模式下 n>1 会拆成多个 n=1 的独立流式任务,并按服务端 key 容量并发执行。
|
| 74 |
# 默认 sticky 路由按单个 credential 容量推荐并发;round_robin/random 才会使用完整 credential 池。
|
| 75 |
# key 出现鉴权、额度或限流类错误后会短暂冷却;渠道出现 5xx、CDN 超时或连接错误后会冷却整个渠道。
|
| 76 |
+
# 如果失败能关联到本次服务端 request mode,只冷却对应 request mode,不误伤同渠道其他可用方式。
|
| 77 |
# OPENAI_MAX_STREAMS_PER_CREDENTIAL=1
|
| 78 |
# OPENAI_CHANNEL_QUEUE_ENABLED=true
|
| 79 |
# OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS=420000
|
|
|
|
| 83 |
# OPENAI_CHANNEL_FAILURE_COOLDOWN_MS=30000
|
| 84 |
#
|
| 85 |
# 可选:服务端渠道恢复探测。存在服务端凭证时默认开启,并要求冷却到期的
|
| 86 |
+
# credential/channel/request mode 先通过后台 GET /models 探测,成功后才重新进入用户生图流量。
|
| 87 |
+
# 探测不调用 /images/generations,不触发生图费用;它只确认 host、鉴权和 models 端点恢复,
|
| 88 |
+
# 不能替代 Images/Responses/SSE 的真实 smoke;MAX_PER_TICK 用于限制探测流量。
|
| 89 |
# OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED=true
|
| 90 |
# 如果设为 true,OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED 也必须启用。
|
| 91 |
# OPENAI_CHANNEL_REQUIRE_PROBE_FOR_RECOVERY=true
|
README.md
CHANGED
|
@@ -101,9 +101,10 @@ start-windows.bat
|
|
| 101 |
| 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
|
| 102 |
| Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
|
| 103 |
| 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
|
|
|
|
| 104 |
| 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
|
| 105 |
| 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
|
| 106 |
-
| 失败冷却 | `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED`、`OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 控制失败渠道
|
| 107 |
| 上游超时 | `IMAGE_UPSTREAM_TIMEOUT_MS`、`IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS`、`IMAGE_UPSTREAM_MAX_RETRIES` | 默认按长耗时图片请求处理,SDK 自动重试默认关闭。 |
|
| 108 |
| 日志窗口 | `APP_LOG_LEVEL`、`APP_LOG_MAX_ENTRIES` | 控制服务端日志等级和 Agent 诊断可回溯窗口。 |
|
| 109 |
|
|
@@ -115,10 +116,12 @@ OPENAI_ROUTING_STRATEGY=round_robin
|
|
| 115 |
OPENAI_CHANNEL_1_ID=official
|
| 116 |
OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
|
| 117 |
OPENAI_CHANNEL_1_API_KEYS=your-primary-key
|
|
|
|
| 118 |
|
| 119 |
OPENAI_CHANNEL_2_ID=backup
|
| 120 |
OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1
|
| 121 |
OPENAI_CHANNEL_2_API_KEYS=your-backup-key-a,your-backup-key-b
|
|
|
|
| 122 |
|
| 123 |
OPENAI_CHANNEL_3_ID=matsca
|
| 124 |
OPENAI_CHANNEL_3_BASE_URL=https://img.matsca.com/v1
|
|
@@ -135,6 +138,8 @@ OPENAI_CHANNEL_3_UPSTREAM_PROFILE=matsca
|
|
| 135 |
注意:
|
| 136 |
|
| 137 |
- 自定义 API URL 必须同时填写自定义 API Key,避免服务端密钥被发送到未知地址。
|
|
|
|
|
|
|
| 138 |
- Docker compose 本身不把默认图片后端改成 Responses;未在 `.env.local` 显式配置时仍是 `images-api` 和 `auto`。
|
| 139 |
- Responses image backend 需要 `ENABLE_RESPONSES_IMAGE_BACKEND=true` 和 `OPENAI_RESPONSES_API_MODEL`。页面请求也可以用 `responsesModel`、`responses_model`、`gptModel` 或 `gpt_model` 覆盖单次 `/responses` 顶层模型;这些字段只影响本项目的 `responses-image-generation` 路径,不会改变兼容上游自身 `/v1/images/generations` 桥接层内部选择的模型。
|
| 140 |
- Matsca、extra headers、provider manifest、真实上游 smoke 等高级配置以 [.env.example](./.env.example) 为准。
|
|
@@ -340,12 +345,12 @@ Hugging Face Space 免费层部署见 [docs/deployment/huggingface-space-free.md
|
|
| 340 |
| `npm run verify` | 运行提交前基线。 |
|
| 341 |
| `npm run docker:cleanup-fixtures` | 清理遗留的整仓挂载 Docker fixture 容器。 |
|
| 342 |
| `npm run first-run` | 首次配置就绪检查,默认中文摘要;加 `-- --json` 输出机器可读 JSON。 |
|
| 343 |
-
| `npm run status` | 只读查看 git、Node、部署目标
|
| 344 |
| `npm run doctor` | 运行本机和部署诊断。 |
|
| 345 |
| `npm run agent:doctor` | 非计费 Agent 分层诊断;支持 `-- --base-url <url>`。 |
|
| 346 |
| `npm run deploy:space` | 上传干净 git HEAD 到固定 HF Space。 |
|
| 347 |
|
| 348 |
-
真实上游 smoke 默认不会触发计费;需要真实生图时必须显式传入 `--allow-billable`。
|
| 349 |
|
| 350 |
## 常见问题
|
| 351 |
|
|
|
|
| 101 |
| 默认后端 | `IMAGE_GENERATION_BACKEND` | 默认 `images-api`;可设为 `responses-image-generation`。 |
|
| 102 |
| Responses 顶层模型 | `OPENAI_RESPONSES_API_MODEL` | 仅在 `responses-image-generation` 后端生效;作为 `/responses` 的顶层 `model`,例如 `gpt-5.4`。 |
|
| 103 |
| 流式策略 | `IMAGE_STREAMING_STRATEGY` | 默认 `auto`;可设为 `off`、`openai-sse`、`responses-sse` 等。 |
|
| 104 |
+
| 渠道请求方式 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 可选。声明全局或单渠道可用方式:`images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`;旧后端名别名仅为兼容输入,建议使用这四个规范值。 |
|
| 105 |
| 并发容量 | `OPENAI_MAX_STREAMS_PER_CREDENTIAL` | 单个渠道凭证允许同时执行的图片请求数,默认 `1`。 |
|
| 106 |
| 渠道队列 | `OPENAI_CHANNEL_QUEUE_ENABLED`、`OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS`、`OPENAI_CHANNEL_QUEUE_MAX_SIZE` | 控制超出凭证容量时等待还是立即失败。 |
|
| 107 |
+
| 失败冷却 | `OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED`、`OPENAI_CHANNEL_FAILURE_COOLDOWN_MS` | 控制失败渠道、凭证或已识别请求方式是否临时移出路由池。 |
|
| 108 |
| 上游超时 | `IMAGE_UPSTREAM_TIMEOUT_MS`、`IMAGE_STREAM_DATA_INTERVAL_TIMEOUT_MS`、`IMAGE_UPSTREAM_MAX_RETRIES` | 默认按长耗时图片请求处理,SDK 自动重试默认关闭。 |
|
| 109 |
| 日志窗口 | `APP_LOG_LEVEL`、`APP_LOG_MAX_ENTRIES` | 控制服务端日志等级和 Agent 诊断可回溯窗口。 |
|
| 110 |
|
|
|
|
| 116 |
OPENAI_CHANNEL_1_ID=official
|
| 117 |
OPENAI_CHANNEL_1_BASE_URL=https://api.openai.com/v1
|
| 118 |
OPENAI_CHANNEL_1_API_KEYS=your-primary-key
|
| 119 |
+
OPENAI_CHANNEL_1_REQUEST_MODES=images-non-stream,images-sse
|
| 120 |
|
| 121 |
OPENAI_CHANNEL_2_ID=backup
|
| 122 |
OPENAI_CHANNEL_2_BASE_URL=https://your-compatible-api.example.com/v1
|
| 123 |
OPENAI_CHANNEL_2_API_KEYS=your-backup-key-a,your-backup-key-b
|
| 124 |
+
OPENAI_CHANNEL_2_REQUEST_MODES=images-non-stream
|
| 125 |
|
| 126 |
OPENAI_CHANNEL_3_ID=matsca
|
| 127 |
OPENAI_CHANNEL_3_BASE_URL=https://img.matsca.com/v1
|
|
|
|
| 138 |
注意:
|
| 139 |
|
| 140 |
- 自定义 API URL 必须同时填写自定义 API Key,避免服务端密钥被发送到未知地址。
|
| 141 |
+
- `OPENAI_CHANNEL_N_REQUEST_MODES` 是管理员基于真实上游 smoke 设置的服务端白名单;全局默认可用 `OPENAI_UPSTREAM_REQUEST_MODES`。`/api/runtime-capabilities` 的 `channelRouting.requestModeControls`、`channelRouting.requestModeHealth` 和 Agent capabilities 的 `request_mode_controls` 会暴露配置入口、健康覆盖和对应真实 smoke gate。Agent 客户端只提交业务意图,不应自行选择 Images、Responses、SSE 或非流式路径。`stream_mode=auto` 可由服务端在 SSE 不可用时显式退到非流式并标记 fallback;`stream_mode=stream` 或显式页面 SSE 诊断必须失败可见,不会静默降级。真实执行后可从 `execution.channel_request_mode`、`execution.channel_request_mode_fallback_applied` 和 `execution.route_decision` 读取服务端实际选路结果;失败冷却若能关联到本次服务端 request mode,会只冷却该渠道或凭证的对应 request mode,并在 `error.diagnostics.cooldown_target.request_mode` 暴露。
|
| 142 |
+
- 渠道恢复探测使用非计费 `GET /models` 只确认 host、鉴权和 models 端点恢复;它不能替代 request mode 的真实 Images/Responses/SSE smoke。管理员应以真实 smoke 结果决定 `OPENAI_CHANNEL_N_REQUEST_MODES`。
|
| 143 |
- Docker compose 本身不把默认图片后端改成 Responses;未在 `.env.local` 显式配置时仍是 `images-api` 和 `auto`。
|
| 144 |
- Responses image backend 需要 `ENABLE_RESPONSES_IMAGE_BACKEND=true` 和 `OPENAI_RESPONSES_API_MODEL`。页面请求也可以用 `responsesModel`、`responses_model`、`gptModel` 或 `gpt_model` 覆盖单次 `/responses` 顶层模型;这些字段只影响本项目的 `responses-image-generation` 路径,不会改变兼容上游自身 `/v1/images/generations` 桥接层内部选择的模型。
|
| 145 |
- Matsca、extra headers、provider manifest、真实上游 smoke 等高级配置以 [.env.example](./.env.example) 为准。
|
|
|
|
| 345 |
| `npm run verify` | 运行提交前基线。 |
|
| 346 |
| `npm run docker:cleanup-fixtures` | 清理遗留的整仓挂载 Docker fixture 容器。 |
|
| 347 |
| `npm run first-run` | 首次配置就绪检查,默认中文摘要;加 `-- --json` 输出机器可读 JSON。 |
|
| 348 |
+
| `npm run status` | 只读查看 git、Node、部署目标、Agent 摘要和真实 smoke 配置状态;不执行计费图片请求。 |
|
| 349 |
| `npm run doctor` | 运行本机和部署诊断。 |
|
| 350 |
| `npm run agent:doctor` | 非计费 Agent 分层诊断;支持 `-- --base-url <url>`。 |
|
| 351 |
| `npm run deploy:space` | 上传干净 git HEAD 到固定 HF Space。 |
|
| 352 |
|
| 353 |
+
真实上游 smoke 默认不会触发计费;`npm run status` 只报告 `configuration_complete` 和 `smoke_state=not_run_by_status` 等配置口径。需要真实生图验证时必须显式传入 `--allow-billable`。
|
| 354 |
|
| 355 |
## 常见问题
|
| 356 |
|
public/hf-space-deploy-marker.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
-
"local_sha": "
|
| 4 |
-
"created_at": "2026-06-
|
| 5 |
-
"deploy_id": "
|
| 6 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"schema_version": 1,
|
| 3 |
+
"local_sha": "34544ccd35f4321f92ab73ef5ac36603ee9f7720",
|
| 4 |
+
"created_at": "2026-06-24T14:38:59.964Z",
|
| 5 |
+
"deploy_id": "dee10b1a-4b9c-47bb-a7f3-89875a64183f"
|
| 6 |
}
|
scripts/agent-doctor.mjs
CHANGED
|
@@ -7,11 +7,13 @@ import {
|
|
| 7 |
loadPrivateAgentEnvFile,
|
| 8 |
resolvePlaygroundBaseUrl
|
| 9 |
} from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
|
|
|
|
| 10 |
|
| 11 |
const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
|
| 12 |
const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
|
| 13 |
const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
|
| 14 |
const PAGE_SSE_GENERATE_SMOKE_NAME = 'responses_page_sse_generate_1k';
|
|
|
|
| 15 |
|
| 16 |
export function buildAgentDoctorArgs() {
|
| 17 |
return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
|
|
@@ -131,6 +133,7 @@ function buildSkippedSmoke(options) {
|
|
| 131 |
checks: [
|
| 132 |
{ name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' },
|
| 133 |
{ name: PAGE_SSE_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
|
|
|
|
| 134 |
{
|
| 135 |
name: 'edit_1k',
|
| 136 |
skipped: true,
|
|
@@ -184,6 +187,26 @@ function runBillableSmoke(options, baseUrl) {
|
|
| 184 |
'--idempotency-key',
|
| 185 |
`agent-doctor-responses-page-sse-generate-${Date.now()}`,
|
| 186 |
'agent doctor responses page SSE generate smoke'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
])
|
| 188 |
];
|
| 189 |
if (options.editImage) {
|
|
@@ -302,7 +325,9 @@ function summarizeCapabilities(body) {
|
|
| 302 |
: undefined,
|
| 303 |
agent_jobs: body?.agent_jobs?.supported === true,
|
| 304 |
routing_rules: Boolean(body?.routing_rules),
|
| 305 |
-
executable_routing_rules: Boolean(body?.routing_rules?.high_resolution_edit?.conditions)
|
|
|
|
|
|
|
| 306 |
};
|
| 307 |
}
|
| 308 |
|
|
@@ -311,7 +336,10 @@ function summarizeRuntime(body) {
|
|
| 311 |
default_stream_mode: body?.streaming?.defaultMode,
|
| 312 |
streaming_unavailable_scope: body?.streaming?.unavailableMarkScope,
|
| 313 |
responses_image_backend: body?.responsesImageBackend?.enabled === true,
|
| 314 |
-
streaming_batch_enabled: body?.streamingBatch?.enabled === true
|
|
|
|
|
|
|
|
|
|
| 315 |
};
|
| 316 |
}
|
| 317 |
|
|
@@ -333,7 +361,11 @@ function summarizeResponsesReadiness(capabilities, runtime) {
|
|
| 333 |
missing_env: requirements?.missing_env || [],
|
| 334 |
gpt2image_real_smoke_case: 'gpt2image-responses-sse',
|
| 335 |
real_smoke_gate:
|
|
|
|
|
|
|
|
|
|
| 336 |
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
|
|
|
|
| 337 |
};
|
| 338 |
}
|
| 339 |
|
|
@@ -352,7 +384,13 @@ function buildSummary({ capabilities, runtime, contract, smoke }) {
|
|
| 352 |
: capabilities.ok,
|
| 353 |
page_sse_real_smoke: summarizePageSseSmoke(smoke),
|
| 354 |
responses_page_sse_generate_smoke: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
|
|
|
|
| 355 |
real_smoke_checks: summarizeSmokeChecks(smoke),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
responses_gpt2image_ready:
|
| 357 |
capabilities.ok && runtime.ok
|
| 358 |
? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true &&
|
|
@@ -382,11 +420,70 @@ function summarizeSmokeChecks(smoke) {
|
|
| 382 |
return {
|
| 383 |
agent_generate_1k: summarizeSmokeCheck(smoke, 'generate_1k'),
|
| 384 |
responses_page_sse_generate_1k: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
|
|
|
|
| 385 |
agent_edit_1k: summarizeSmokeCheck(smoke, 'edit_1k'),
|
| 386 |
page_sse_edit_2k: summarizeSmokeCheck(smoke, 'page_sse_edit_2k')
|
| 387 |
};
|
| 388 |
}
|
| 389 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
function authHeaders() {
|
| 391 |
if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
|
| 392 |
if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
|
|
|
|
| 7 |
loadPrivateAgentEnvFile,
|
| 8 |
resolvePlaygroundBaseUrl
|
| 9 |
} from '../skills/gpt-image-playground-agent/scripts/lib/script-utils.mjs';
|
| 10 |
+
import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_SMOKE_CASES } from '../src/lib/channel-request-mode-values.mjs';
|
| 11 |
|
| 12 |
const GENERATE_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/generate-image.mjs', import.meta.url));
|
| 13 |
const EDIT_SCRIPT = fileURLToPath(new URL('../skills/gpt-image-playground-agent/scripts/edit-image.mjs', import.meta.url));
|
| 14 |
const AGENT_DOCTOR_TIMEOUT_MS = 75_000;
|
| 15 |
const PAGE_SSE_GENERATE_SMOKE_NAME = 'responses_page_sse_generate_1k';
|
| 16 |
+
const RESPONSES_AGENT_GENERATE_SMOKE_NAME = 'responses_agent_generate_1k';
|
| 17 |
|
| 18 |
export function buildAgentDoctorArgs() {
|
| 19 |
return [GENERATE_SCRIPT, '--contract-check', '--timeout-ms', '60000', 'contract check'];
|
|
|
|
| 133 |
checks: [
|
| 134 |
{ name: 'generate_1k', skipped: true, reason: 'requires --allow-billable' },
|
| 135 |
{ name: PAGE_SSE_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
|
| 136 |
+
{ name: RESPONSES_AGENT_GENERATE_SMOKE_NAME, skipped: true, reason: 'requires --allow-billable' },
|
| 137 |
{
|
| 138 |
name: 'edit_1k',
|
| 139 |
skipped: true,
|
|
|
|
| 187 |
'--idempotency-key',
|
| 188 |
`agent-doctor-responses-page-sse-generate-${Date.now()}`,
|
| 189 |
'agent doctor responses page SSE generate smoke'
|
| 190 |
+
]),
|
| 191 |
+
runSmokeCommand(RESPONSES_AGENT_GENERATE_SMOKE_NAME, [
|
| 192 |
+
GENERATE_SCRIPT,
|
| 193 |
+
'--base-url',
|
| 194 |
+
baseUrl,
|
| 195 |
+
'--allow-billable',
|
| 196 |
+
'--agent',
|
| 197 |
+
'--timeout-ms',
|
| 198 |
+
String(options.timeoutMs),
|
| 199 |
+
'--size',
|
| 200 |
+
'1024x1024',
|
| 201 |
+
'--quality',
|
| 202 |
+
'low',
|
| 203 |
+
'--image-backend',
|
| 204 |
+
'responses-image-generation',
|
| 205 |
+
'--stream-mode',
|
| 206 |
+
'non_stream',
|
| 207 |
+
'--idempotency-key',
|
| 208 |
+
`agent-doctor-responses-agent-generate-${Date.now()}`,
|
| 209 |
+
'agent doctor responses non-stream generate smoke'
|
| 210 |
])
|
| 211 |
];
|
| 212 |
if (options.editImage) {
|
|
|
|
| 325 |
: undefined,
|
| 326 |
agent_jobs: body?.agent_jobs?.supported === true,
|
| 327 |
routing_rules: Boolean(body?.routing_rules),
|
| 328 |
+
executable_routing_rules: Boolean(body?.routing_rules?.high_resolution_edit?.conditions),
|
| 329 |
+
request_modes_supported: readRequestModeList(body?.supported?.request_modes),
|
| 330 |
+
request_modes_by_channel: readCapabilitiesRequestModesByChannel(body)
|
| 331 |
};
|
| 332 |
}
|
| 333 |
|
|
|
|
| 336 |
default_stream_mode: body?.streaming?.defaultMode,
|
| 337 |
streaming_unavailable_scope: body?.streaming?.unavailableMarkScope,
|
| 338 |
responses_image_backend: body?.responsesImageBackend?.enabled === true,
|
| 339 |
+
streaming_batch_enabled: body?.streamingBatch?.enabled === true,
|
| 340 |
+
configured_request_modes: readRequestModeList(body?.channelRouting?.configuredRequestModes),
|
| 341 |
+
effective_request_modes: readRequestModeList(body?.channelRouting?.effectiveRequestModes),
|
| 342 |
+
effective_request_modes_by_channel: readRuntimeRequestModesByChannel(body)
|
| 343 |
};
|
| 344 |
}
|
| 345 |
|
|
|
|
| 361 |
missing_env: requirements?.missing_env || [],
|
| 362 |
gpt2image_real_smoke_case: 'gpt2image-responses-sse',
|
| 363 |
real_smoke_gate:
|
| 364 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable',
|
| 365 |
+
real_smoke_gates: [
|
| 366 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-responses-json --allow-billable',
|
| 367 |
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
|
| 368 |
+
]
|
| 369 |
};
|
| 370 |
}
|
| 371 |
|
|
|
|
| 384 |
: capabilities.ok,
|
| 385 |
page_sse_real_smoke: summarizePageSseSmoke(smoke),
|
| 386 |
responses_page_sse_generate_smoke: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
|
| 387 |
+
responses_agent_generate_smoke: summarizeSmokeCheck(smoke, RESPONSES_AGENT_GENERATE_SMOKE_NAME),
|
| 388 |
real_smoke_checks: summarizeSmokeChecks(smoke),
|
| 389 |
+
request_modes: buildRequestModeSummary({
|
| 390 |
+
capabilities: capabilities.ok ? capabilities.body : undefined,
|
| 391 |
+
runtime: runtime.ok ? runtime.body : undefined,
|
| 392 |
+
smoke
|
| 393 |
+
}),
|
| 394 |
responses_gpt2image_ready:
|
| 395 |
capabilities.ok && runtime.ok
|
| 396 |
? capabilities.body?.supported?.image_backend_requirements?.['responses-image-generation']?.enabled === true &&
|
|
|
|
| 420 |
return {
|
| 421 |
agent_generate_1k: summarizeSmokeCheck(smoke, 'generate_1k'),
|
| 422 |
responses_page_sse_generate_1k: summarizeSmokeCheck(smoke, PAGE_SSE_GENERATE_SMOKE_NAME),
|
| 423 |
+
responses_agent_generate_1k: summarizeSmokeCheck(smoke, RESPONSES_AGENT_GENERATE_SMOKE_NAME),
|
| 424 |
agent_edit_1k: summarizeSmokeCheck(smoke, 'edit_1k'),
|
| 425 |
page_sse_edit_2k: summarizeSmokeCheck(smoke, 'page_sse_edit_2k')
|
| 426 |
};
|
| 427 |
}
|
| 428 |
|
| 429 |
+
function buildRequestModeSummary({ capabilities, runtime, smoke }) {
|
| 430 |
+
const supported = readRequestModeList(capabilities?.supported?.request_modes);
|
| 431 |
+
const configured = readRequestModeList(runtime?.channelRouting?.configuredRequestModes);
|
| 432 |
+
const effective = readRequestModeList(runtime?.channelRouting?.effectiveRequestModes);
|
| 433 |
+
return {
|
| 434 |
+
supported,
|
| 435 |
+
configured,
|
| 436 |
+
effective,
|
| 437 |
+
admin_whitelist_by_channel: readCapabilitiesRequestModesByChannel(capabilities),
|
| 438 |
+
effective_by_channel: readRuntimeRequestModesByChannel(runtime),
|
| 439 |
+
smoke: Object.fromEntries(
|
| 440 |
+
CHANNEL_REQUEST_MODES.map((mode) => [mode, summarizeRequestModeSmoke(smoke, mode)])
|
| 441 |
+
)
|
| 442 |
+
};
|
| 443 |
+
}
|
| 444 |
+
|
| 445 |
+
function summarizeRequestModeSmoke(smoke, mode) {
|
| 446 |
+
const checks = CHANNEL_REQUEST_MODE_SMOKE_CASES[mode] || [];
|
| 447 |
+
if (checks.length === 0) {
|
| 448 |
+
return {
|
| 449 |
+
state: 'skipped',
|
| 450 |
+
checks: [],
|
| 451 |
+
billable: false
|
| 452 |
+
};
|
| 453 |
+
}
|
| 454 |
+
const states = checks.map((name) => summarizeSmokeCheck(smoke, name));
|
| 455 |
+
return {
|
| 456 |
+
state: states.includes('failed') ? 'failed' : states.includes('passed') ? 'passed' : 'skipped',
|
| 457 |
+
checks,
|
| 458 |
+
billable: smoke.skipped !== true
|
| 459 |
+
};
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
function readCapabilitiesRequestModesByChannel(body) {
|
| 463 |
+
const channels = Array.isArray(body?.upstream_request_headers?.channels)
|
| 464 |
+
? body.upstream_request_headers.channels
|
| 465 |
+
: [];
|
| 466 |
+
return channels.map((channel) => ({
|
| 467 |
+
channel_id: String(channel?.id || ''),
|
| 468 |
+
request_modes: readRequestModeList(channel?.request_modes)
|
| 469 |
+
})).filter((channel) => channel.channel_id);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function readRuntimeRequestModesByChannel(body) {
|
| 473 |
+
const channels = Array.isArray(body?.channelRouting?.effectiveRequestModesByChannel)
|
| 474 |
+
? body.channelRouting.effectiveRequestModesByChannel
|
| 475 |
+
: [];
|
| 476 |
+
return channels.map((channel) => ({
|
| 477 |
+
channel_id: String(channel?.channelId || ''),
|
| 478 |
+
request_modes: readRequestModeList(channel?.requestModes)
|
| 479 |
+
})).filter((channel) => channel.channel_id);
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
function readRequestModeList(value) {
|
| 483 |
+
if (!Array.isArray(value)) return [];
|
| 484 |
+
return CHANNEL_REQUEST_MODES.filter((mode) => value.includes(mode));
|
| 485 |
+
}
|
| 486 |
+
|
| 487 |
function authHeaders() {
|
| 488 |
if (process.env.GPT_IMAGE_AGENT_TOKEN) return { Authorization: `Bearer ${process.env.GPT_IMAGE_AGENT_TOKEN}` };
|
| 489 |
if (process.env.GPT_IMAGE_APP_PASSWORD_HASH) return { 'X-App-Password-Hash': process.env.GPT_IMAGE_APP_PASSWORD_HASH };
|
scripts/agent-skill-scripts.test.mjs
CHANGED
|
@@ -631,7 +631,7 @@ describe('Agent skill script argument validation', () => {
|
|
| 631 |
assert.equal(result.stderr.trim(), '');
|
| 632 |
});
|
| 633 |
|
| 634 |
-
it('
|
| 635 |
const requests = [];
|
| 636 |
let imageRequestBody = '';
|
| 637 |
await withServer(
|
|
@@ -639,7 +639,32 @@ describe('Agent skill script argument validation', () => {
|
|
| 639 |
requests.push({ method: request.method, url: request.url });
|
| 640 |
if (request.url === '/api/agent/capabilities') {
|
| 641 |
response.writeHead(200, { 'content-type': 'application/json' });
|
| 642 |
-
response.end(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
return;
|
| 644 |
}
|
| 645 |
if (request.url === '/api/agent/image-requests') {
|
|
@@ -679,7 +704,17 @@ describe('Agent skill script argument validation', () => {
|
|
| 679 |
route_mode: 'job',
|
| 680 |
image_backend: 'images-api',
|
| 681 |
stream_mode: 'non_stream',
|
| 682 |
-
streaming_strategy: 'off'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 683 |
},
|
| 684 |
timing: { server_elapsed_ms: 1234 }
|
| 685 |
})
|
|
@@ -720,6 +755,16 @@ describe('Agent skill script argument validation', () => {
|
|
| 720 |
assert.equal(body.summary.transport, 'agent_job_polling');
|
| 721 |
assert.equal(body.summary.endpoint, '/api/agent/image-requests');
|
| 722 |
assert.equal(body.summary.route_mode, 'job');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-orchestrated-1/content']);
|
| 724 |
assert.deepEqual(body.summary.actual_dimensions, { width: 1254, height: 1254 });
|
| 725 |
assert.deepEqual(
|
|
@@ -1503,9 +1548,26 @@ describe('Agent skill script argument validation', () => {
|
|
| 1503 |
code: 'unexpected_error',
|
| 1504 |
retryable: false,
|
| 1505 |
diagnostics: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1506 |
selected_channel_id: 'channel-a',
|
| 1507 |
upstream_host: 'upstream.example.test',
|
| 1508 |
-
transport_error_kind: 'aborted'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1509 |
}
|
| 1510 |
}
|
| 1511 |
}
|
|
@@ -1527,13 +1589,43 @@ describe('Agent skill script argument validation', () => {
|
|
| 1527 |
assert.equal(result.stdout.trim(), '');
|
| 1528 |
const body = JSON.parse(result.stderr);
|
| 1529 |
assert.equal(body.summary.request_id, 'req_generate_diag');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1530 |
assert.equal(body.summary.selected_channel_id, 'channel-a');
|
| 1531 |
assert.equal(body.summary.upstream_host, 'upstream.example.test');
|
| 1532 |
assert.equal(body.summary.transport_error_kind, 'aborted');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1533 |
assert.equal(body.summary.agent_diagnostics_checked, true);
|
| 1534 |
assert.equal(body.summary.agent_diagnostics_found, true);
|
| 1535 |
assert.equal(body.agent_failure_diagnostics.request_id, 'req_generate_diag');
|
| 1536 |
assert.equal(body.agent_failure_diagnostics.status, 'failed');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1537 |
assert.deepEqual(
|
| 1538 |
requests.map((item) => `${item.method} ${item.url}`),
|
| 1539 |
[
|
|
@@ -3491,6 +3583,9 @@ describe('Agent skill script argument validation', () => {
|
|
| 3491 |
assert.match(skillText, /summary\.page_sse_real_smoke/);
|
| 3492 |
assert.match(skillText, /兼容聚合状态/);
|
| 3493 |
assert.match(skillText, /summary\.responses_page_sse_generate_smoke/);
|
|
|
|
|
|
|
|
|
|
| 3494 |
assert.match(skillText, /summary\.real_smoke_checks/);
|
| 3495 |
assert.match(apiReference, /npm run first-run/);
|
| 3496 |
assert.match(apiReference, /npm run first-run -- --json/);
|
|
@@ -3501,6 +3596,9 @@ describe('Agent skill script argument validation', () => {
|
|
| 3501 |
assert.match(apiReference, /summary\.page_sse_real_smoke/);
|
| 3502 |
assert.match(apiReference, /兼容聚合状态/);
|
| 3503 |
assert.match(apiReference, /summary\.responses_page_sse_generate_smoke/);
|
|
|
|
|
|
|
|
|
|
| 3504 |
assert.match(apiReference, /summary\.real_smoke_checks/);
|
| 3505 |
assert.match(readmeText, /不要手动并行启动多个单张脚本/);
|
| 3506 |
assert.match(readmeText, /streamingBatch\.recommendedConcurrency/);
|
|
@@ -6104,7 +6202,10 @@ describe('Agent skill script argument validation', () => {
|
|
| 6104 |
diagnostics: {
|
| 6105 |
transport_error_kind: 'dns',
|
| 6106 |
cooldown_until: '2026-06-11T12:00:00.000Z',
|
| 6107 |
-
cooldown_target: {
|
|
|
|
|
|
|
|
|
|
| 6108 |
}
|
| 6109 |
}
|
| 6110 |
}
|
|
@@ -6138,6 +6239,10 @@ describe('Agent skill script argument validation', () => {
|
|
| 6138 |
);
|
| 6139 |
assert.equal(body.agent_requests[1].lookup.type, 'idempotency_key');
|
| 6140 |
assert.equal(body.agent_requests[1].diagnostics.error.diagnostics.transport_error_kind, 'dns');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6141 |
assert.deepEqual(
|
| 6142 |
requests.map((item) => `${item.method} ${item.url}`),
|
| 6143 |
[
|
|
|
|
| 631 |
assert.equal(result.stderr.trim(), '');
|
| 632 |
});
|
| 633 |
|
| 634 |
+
it('keeps default generate on server orchestration when request modes are declared', async () => {
|
| 635 |
const requests = [];
|
| 636 |
let imageRequestBody = '';
|
| 637 |
await withServer(
|
|
|
|
| 639 |
requests.push({ method: request.method, url: request.url });
|
| 640 |
if (request.url === '/api/agent/capabilities') {
|
| 641 |
response.writeHead(200, { 'content-type': 'application/json' });
|
| 642 |
+
response.end(
|
| 643 |
+
JSON.stringify(
|
| 644 |
+
agentGenerateCapabilities({
|
| 645 |
+
supported: {
|
| 646 |
+
request_modes: [
|
| 647 |
+
'images-non-stream',
|
| 648 |
+
'images-sse',
|
| 649 |
+
'responses-non-stream',
|
| 650 |
+
'responses-sse'
|
| 651 |
+
]
|
| 652 |
+
},
|
| 653 |
+
upstream_request_headers: {
|
| 654 |
+
channels: [
|
| 655 |
+
{
|
| 656 |
+
id: 'images',
|
| 657 |
+
request_modes: ['images-non-stream', 'images-sse']
|
| 658 |
+
},
|
| 659 |
+
{
|
| 660 |
+
id: 'responses',
|
| 661 |
+
request_modes: ['responses-sse']
|
| 662 |
+
}
|
| 663 |
+
]
|
| 664 |
+
}
|
| 665 |
+
})
|
| 666 |
+
)
|
| 667 |
+
);
|
| 668 |
return;
|
| 669 |
}
|
| 670 |
if (request.url === '/api/agent/image-requests') {
|
|
|
|
| 704 |
route_mode: 'job',
|
| 705 |
image_backend: 'images-api',
|
| 706 |
stream_mode: 'non_stream',
|
| 707 |
+
streaming_strategy: 'off',
|
| 708 |
+
channel_request_mode: 'images-non-stream',
|
| 709 |
+
channel_request_mode_fallback_applied: false,
|
| 710 |
+
route_decision: {
|
| 711 |
+
requested_backend: 'images-api',
|
| 712 |
+
preferred_channel_request_mode: 'images-non-stream',
|
| 713 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 714 |
+
fallback_applied: false,
|
| 715 |
+
selected_channel_id: 'channel-orchestrated',
|
| 716 |
+
upstream_host: 'upstream.example.test'
|
| 717 |
+
}
|
| 718 |
},
|
| 719 |
timing: { server_elapsed_ms: 1234 }
|
| 720 |
})
|
|
|
|
| 755 |
assert.equal(body.summary.transport, 'agent_job_polling');
|
| 756 |
assert.equal(body.summary.endpoint, '/api/agent/image-requests');
|
| 757 |
assert.equal(body.summary.route_mode, 'job');
|
| 758 |
+
assert.equal(body.summary.channel_request_mode, 'images-non-stream');
|
| 759 |
+
assert.equal(body.summary.channel_request_mode_fallback_applied, false);
|
| 760 |
+
assert.deepEqual(body.summary.route_decision, {
|
| 761 |
+
requested_backend: 'images-api',
|
| 762 |
+
preferred_channel_request_mode: 'images-non-stream',
|
| 763 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 764 |
+
fallback_applied: false,
|
| 765 |
+
selected_channel_id: 'channel-orchestrated',
|
| 766 |
+
upstream_host: 'upstream.example.test'
|
| 767 |
+
});
|
| 768 |
assert.deepEqual(body.summary.content_urls, ['/api/agent/artifacts/artifact-orchestrated-1/content']);
|
| 769 |
assert.deepEqual(body.summary.actual_dimensions, { width: 1254, height: 1254 });
|
| 770 |
assert.deepEqual(
|
|
|
|
| 1548 |
code: 'unexpected_error',
|
| 1549 |
retryable: false,
|
| 1550 |
diagnostics: {
|
| 1551 |
+
channel_request_mode: 'images-non-stream',
|
| 1552 |
+
channel_request_mode_fallback_applied: true,
|
| 1553 |
+
route_decision: {
|
| 1554 |
+
requested_backend: 'images-api',
|
| 1555 |
+
preferred_channel_request_mode: 'images-sse',
|
| 1556 |
+
fallback_channel_request_mode: 'images-non-stream',
|
| 1557 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 1558 |
+
fallback_applied: true,
|
| 1559 |
+
selected_channel_id: 'channel-a',
|
| 1560 |
+
upstream_host: 'upstream.example.test'
|
| 1561 |
+
},
|
| 1562 |
selected_channel_id: 'channel-a',
|
| 1563 |
upstream_host: 'upstream.example.test',
|
| 1564 |
+
transport_error_kind: 'aborted',
|
| 1565 |
+
retry_after_ms: 30000,
|
| 1566 |
+
cooldown_until: '2026-06-11T12:00:00.000Z',
|
| 1567 |
+
cooldown_target: {
|
| 1568 |
+
channel_id: 'channel-a',
|
| 1569 |
+
request_mode: 'images-non-stream'
|
| 1570 |
+
}
|
| 1571 |
}
|
| 1572 |
}
|
| 1573 |
}
|
|
|
|
| 1589 |
assert.equal(result.stdout.trim(), '');
|
| 1590 |
const body = JSON.parse(result.stderr);
|
| 1591 |
assert.equal(body.summary.request_id, 'req_generate_diag');
|
| 1592 |
+
assert.equal(body.summary.channel_request_mode, 'images-non-stream');
|
| 1593 |
+
assert.equal(body.summary.channel_request_mode_fallback_applied, true);
|
| 1594 |
+
assert.deepEqual(body.summary.route_decision, {
|
| 1595 |
+
requested_backend: 'images-api',
|
| 1596 |
+
preferred_channel_request_mode: 'images-sse',
|
| 1597 |
+
fallback_channel_request_mode: 'images-non-stream',
|
| 1598 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 1599 |
+
fallback_applied: true,
|
| 1600 |
+
selected_channel_id: 'channel-a',
|
| 1601 |
+
upstream_host: 'upstream.example.test'
|
| 1602 |
+
});
|
| 1603 |
assert.equal(body.summary.selected_channel_id, 'channel-a');
|
| 1604 |
assert.equal(body.summary.upstream_host, 'upstream.example.test');
|
| 1605 |
assert.equal(body.summary.transport_error_kind, 'aborted');
|
| 1606 |
+
assert.equal(body.summary.retry_after_ms, 30000);
|
| 1607 |
+
assert.equal(body.summary.cooldown_until, '2026-06-11T12:00:00.000Z');
|
| 1608 |
+
assert.deepEqual(body.summary.cooldown_target, {
|
| 1609 |
+
channel_id: 'channel-a',
|
| 1610 |
+
request_mode: 'images-non-stream'
|
| 1611 |
+
});
|
| 1612 |
assert.equal(body.summary.agent_diagnostics_checked, true);
|
| 1613 |
assert.equal(body.summary.agent_diagnostics_found, true);
|
| 1614 |
assert.equal(body.agent_failure_diagnostics.request_id, 'req_generate_diag');
|
| 1615 |
assert.equal(body.agent_failure_diagnostics.status, 'failed');
|
| 1616 |
+
assert.deepEqual(body.agent_failure_diagnostics.cooldown_target, {
|
| 1617 |
+
channel_id: 'channel-a',
|
| 1618 |
+
request_mode: 'images-non-stream'
|
| 1619 |
+
});
|
| 1620 |
+
assert.deepEqual(body.agent_failure_diagnostics.route_decision, {
|
| 1621 |
+
requested_backend: 'images-api',
|
| 1622 |
+
preferred_channel_request_mode: 'images-sse',
|
| 1623 |
+
fallback_channel_request_mode: 'images-non-stream',
|
| 1624 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 1625 |
+
fallback_applied: true,
|
| 1626 |
+
selected_channel_id: 'channel-a',
|
| 1627 |
+
upstream_host: 'upstream.example.test'
|
| 1628 |
+
});
|
| 1629 |
assert.deepEqual(
|
| 1630 |
requests.map((item) => `${item.method} ${item.url}`),
|
| 1631 |
[
|
|
|
|
| 3583 |
assert.match(skillText, /summary\.page_sse_real_smoke/);
|
| 3584 |
assert.match(skillText, /兼容聚合状态/);
|
| 3585 |
assert.match(skillText, /summary\.responses_page_sse_generate_smoke/);
|
| 3586 |
+
assert.match(skillText, /summary\.responses_agent_generate_smoke/);
|
| 3587 |
+
assert.match(skillText, /responses_agent_generate_1k/);
|
| 3588 |
+
assert.match(skillText, /request_mode_controls/);
|
| 3589 |
assert.match(skillText, /summary\.real_smoke_checks/);
|
| 3590 |
assert.match(apiReference, /npm run first-run/);
|
| 3591 |
assert.match(apiReference, /npm run first-run -- --json/);
|
|
|
|
| 3596 |
assert.match(apiReference, /summary\.page_sse_real_smoke/);
|
| 3597 |
assert.match(apiReference, /兼容聚合状态/);
|
| 3598 |
assert.match(apiReference, /summary\.responses_page_sse_generate_smoke/);
|
| 3599 |
+
assert.match(apiReference, /summary\.responses_agent_generate_smoke/);
|
| 3600 |
+
assert.match(apiReference, /responses_agent_generate_1k/);
|
| 3601 |
+
assert.match(apiReference, /request_mode_controls/);
|
| 3602 |
assert.match(apiReference, /summary\.real_smoke_checks/);
|
| 3603 |
assert.match(readmeText, /不要手动并行启动多个单张脚本/);
|
| 3604 |
assert.match(readmeText, /streamingBatch\.recommendedConcurrency/);
|
|
|
|
| 6202 |
diagnostics: {
|
| 6203 |
transport_error_kind: 'dns',
|
| 6204 |
cooldown_until: '2026-06-11T12:00:00.000Z',
|
| 6205 |
+
cooldown_target: {
|
| 6206 |
+
channel_id: 'channel-a',
|
| 6207 |
+
request_mode: 'images-non-stream'
|
| 6208 |
+
}
|
| 6209 |
}
|
| 6210 |
}
|
| 6211 |
}
|
|
|
|
| 6239 |
);
|
| 6240 |
assert.equal(body.agent_requests[1].lookup.type, 'idempotency_key');
|
| 6241 |
assert.equal(body.agent_requests[1].diagnostics.error.diagnostics.transport_error_kind, 'dns');
|
| 6242 |
+
assert.equal(
|
| 6243 |
+
body.agent_requests[1].diagnostics.error.diagnostics.cooldown_target.request_mode,
|
| 6244 |
+
'images-non-stream'
|
| 6245 |
+
);
|
| 6246 |
assert.deepEqual(
|
| 6247 |
requests.map((item) => `${item.method} ${item.url}`),
|
| 6248 |
[
|
scripts/command-center.test.mjs
CHANGED
|
@@ -114,6 +114,19 @@ describe('Command center scripts', () => {
|
|
| 114 |
'gpt2image-responses-sse',
|
| 115 |
'matsca-images-sse'
|
| 116 |
]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
assert.deepEqual(missing.missing_env_any['sub2api-responses-json'][0], [
|
| 118 |
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL',
|
| 119 |
'IMAGE_REAL_SMOKE_SUB2API_BASE_URL'
|
|
@@ -140,6 +153,9 @@ describe('Command center scripts', () => {
|
|
| 140 |
assert.equal(configured.configuration_complete, true);
|
| 141 |
assert.equal(configured.configured_count, 6);
|
| 142 |
assert.equal(configured.missing_count, 0);
|
|
|
|
|
|
|
|
|
|
| 143 |
assert.doesNotMatch(JSON.stringify(configured), /secret|example\/v1/);
|
| 144 |
});
|
| 145 |
|
|
@@ -742,6 +758,12 @@ describe('Command center scripts', () => {
|
|
| 742 |
}
|
| 743 |
},
|
| 744 |
supported: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
image_backend_requirements: {
|
| 746 |
'responses-image-generation': {
|
| 747 |
supported: true,
|
|
@@ -749,6 +771,18 @@ describe('Command center scripts', () => {
|
|
| 749 |
missing_env: []
|
| 750 |
}
|
| 751 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
}
|
| 753 |
})
|
| 754 |
);
|
|
@@ -763,7 +797,17 @@ describe('Command center scripts', () => {
|
|
| 763 |
unavailableMarkScope: 'channel+backend+strategy+operation'
|
| 764 |
},
|
| 765 |
streamingBatch: { enabled: true },
|
| 766 |
-
responsesImageBackend: { enabled: true, mode: 'experimental' }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 767 |
})
|
| 768 |
);
|
| 769 |
return;
|
|
@@ -836,9 +880,24 @@ describe('Command center scripts', () => {
|
|
| 836 |
assert.deepEqual(body.summary.real_smoke_checks, {
|
| 837 |
agent_generate_1k: 'skipped',
|
| 838 |
responses_page_sse_generate_1k: 'skipped',
|
|
|
|
| 839 |
agent_edit_1k: 'skipped',
|
| 840 |
page_sse_edit_2k: 'skipped'
|
| 841 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 842 |
assert.equal(body.summary.responses_gpt2image_ready, true);
|
| 843 |
assert.equal(body.summary.responses_image_backend_declared_supported, true);
|
| 844 |
assert.equal(body.summary.billable_smoke, 'skipped');
|
|
@@ -846,13 +905,34 @@ describe('Command center scripts', () => {
|
|
| 846 |
assert.ok(
|
| 847 |
body.layers
|
| 848 |
.find((layer) => layer.name === 'billable_smoke')
|
| 849 |
-
.checks.some((check) => check.name === '
|
| 850 |
);
|
| 851 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 852 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_declared_supported, true);
|
| 853 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_required, true);
|
| 854 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_ready, false);
|
| 855 |
assert.equal(body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').declared_supported, true);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
assert.match(
|
| 857 |
body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_next_action,
|
| 858 |
/GPT_IMAGE_APP_PASSWORD_HASH/
|
|
@@ -986,8 +1066,13 @@ describe('Command center scripts', () => {
|
|
| 986 |
assert.equal(body.interactive_confirmation_required, false);
|
| 987 |
assert.equal(body.summary.page_sse_real_smoke, 'passed');
|
| 988 |
assert.equal(body.summary.responses_page_sse_generate_smoke, 'passed');
|
|
|
|
| 989 |
assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
|
| 990 |
assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'passed');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 991 |
assert.equal(body.summary.real_smoke_checks.agent_edit_1k, 'skipped');
|
| 992 |
assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
|
| 993 |
assert.ok(hits.includes('/api/agent/images/generate'));
|
|
@@ -1074,8 +1159,13 @@ describe('Command center scripts', () => {
|
|
| 1074 |
assert.equal(body.summary.billable_smoke, 'failed');
|
| 1075 |
assert.equal(body.summary.page_sse_real_smoke, 'failed');
|
| 1076 |
assert.equal(body.summary.responses_page_sse_generate_smoke, 'failed');
|
|
|
|
| 1077 |
assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
|
| 1078 |
assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'failed');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1079 |
assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
|
| 1080 |
const pageSseOutput = body.layers
|
| 1081 |
.find((layer) => layer.name === 'billable_smoke')
|
|
|
|
| 114 |
'gpt2image-responses-sse',
|
| 115 |
'matsca-images-sse'
|
| 116 |
]);
|
| 117 |
+
assert.deepEqual(missing.request_modes['images-non-stream'], {
|
| 118 |
+
required_count: 1,
|
| 119 |
+
required_cases: ['original-images-json'],
|
| 120 |
+
configuration_complete: false,
|
| 121 |
+
configured_count: 0,
|
| 122 |
+
configured_cases: [],
|
| 123 |
+
missing_count: 1,
|
| 124 |
+
missing_cases: ['original-images-json'],
|
| 125 |
+
invalid_count: 0,
|
| 126 |
+
invalid_cases: [],
|
| 127 |
+
smoke_state: 'not_run_by_status'
|
| 128 |
+
});
|
| 129 |
+
assert.deepEqual(missing.request_modes['responses-sse'].required_cases, ['gpt2image-responses-sse']);
|
| 130 |
assert.deepEqual(missing.missing_env_any['sub2api-responses-json'][0], [
|
| 131 |
'IMAGE_REAL_SMOKE_SUB2API_RESPONSES_BASE_URL',
|
| 132 |
'IMAGE_REAL_SMOKE_SUB2API_BASE_URL'
|
|
|
|
| 153 |
assert.equal(configured.configuration_complete, true);
|
| 154 |
assert.equal(configured.configured_count, 6);
|
| 155 |
assert.equal(configured.missing_count, 0);
|
| 156 |
+
assert.equal(configured.request_modes['images-sse'].configured_count, 3);
|
| 157 |
+
assert.deepEqual(configured.request_modes['responses-non-stream'].configured_cases, ['sub2api-responses-json']);
|
| 158 |
+
assert.deepEqual(configured.request_modes['responses-sse'].configured_cases, ['gpt2image-responses-sse']);
|
| 159 |
assert.doesNotMatch(JSON.stringify(configured), /secret|example\/v1/);
|
| 160 |
});
|
| 161 |
|
|
|
|
| 758 |
}
|
| 759 |
},
|
| 760 |
supported: {
|
| 761 |
+
request_modes: [
|
| 762 |
+
'images-non-stream',
|
| 763 |
+
'images-sse',
|
| 764 |
+
'responses-non-stream',
|
| 765 |
+
'responses-sse'
|
| 766 |
+
],
|
| 767 |
image_backend_requirements: {
|
| 768 |
'responses-image-generation': {
|
| 769 |
supported: true,
|
|
|
|
| 771 |
missing_env: []
|
| 772 |
}
|
| 773 |
}
|
| 774 |
+
},
|
| 775 |
+
upstream_request_headers: {
|
| 776 |
+
channels: [
|
| 777 |
+
{
|
| 778 |
+
id: 'images',
|
| 779 |
+
request_modes: ['images-non-stream', 'images-sse']
|
| 780 |
+
},
|
| 781 |
+
{
|
| 782 |
+
id: 'responses',
|
| 783 |
+
request_modes: ['responses-sse']
|
| 784 |
+
}
|
| 785 |
+
]
|
| 786 |
}
|
| 787 |
})
|
| 788 |
);
|
|
|
|
| 797 |
unavailableMarkScope: 'channel+backend+strategy+operation'
|
| 798 |
},
|
| 799 |
streamingBatch: { enabled: true },
|
| 800 |
+
responsesImageBackend: { enabled: true, mode: 'experimental' },
|
| 801 |
+
channelRouting: {
|
| 802 |
+
configuredRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'],
|
| 803 |
+
effectiveRequestModes: ['images-non-stream', 'images-sse'],
|
| 804 |
+
effectiveRequestModesByChannel: [
|
| 805 |
+
{
|
| 806 |
+
channelId: 'images',
|
| 807 |
+
requestModes: ['images-non-stream', 'images-sse']
|
| 808 |
+
}
|
| 809 |
+
]
|
| 810 |
+
}
|
| 811 |
})
|
| 812 |
);
|
| 813 |
return;
|
|
|
|
| 880 |
assert.deepEqual(body.summary.real_smoke_checks, {
|
| 881 |
agent_generate_1k: 'skipped',
|
| 882 |
responses_page_sse_generate_1k: 'skipped',
|
| 883 |
+
responses_agent_generate_1k: 'skipped',
|
| 884 |
agent_edit_1k: 'skipped',
|
| 885 |
page_sse_edit_2k: 'skipped'
|
| 886 |
});
|
| 887 |
+
assert.deepEqual(body.summary.request_modes.supported, [
|
| 888 |
+
'images-non-stream',
|
| 889 |
+
'images-sse',
|
| 890 |
+
'responses-non-stream',
|
| 891 |
+
'responses-sse'
|
| 892 |
+
]);
|
| 893 |
+
assert.deepEqual(body.summary.request_modes.configured, ['images-non-stream', 'images-sse', 'responses-sse']);
|
| 894 |
+
assert.deepEqual(body.summary.request_modes.effective, ['images-non-stream', 'images-sse']);
|
| 895 |
+
assert.deepEqual(body.summary.request_modes.smoke['responses-non-stream'].checks, [
|
| 896 |
+
'responses_agent_generate_1k'
|
| 897 |
+
]);
|
| 898 |
+
assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'skipped');
|
| 899 |
+
assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'skipped');
|
| 900 |
+
assert.equal(body.summary.request_modes.smoke['responses-sse'].billable, false);
|
| 901 |
assert.equal(body.summary.responses_gpt2image_ready, true);
|
| 902 |
assert.equal(body.summary.responses_image_backend_declared_supported, true);
|
| 903 |
assert.equal(body.summary.billable_smoke, 'skipped');
|
|
|
|
| 905 |
assert.ok(
|
| 906 |
body.layers
|
| 907 |
.find((layer) => layer.name === 'billable_smoke')
|
| 908 |
+
.checks.some((check) => check.name === 'responses_agent_generate_1k' && check.skipped === true)
|
| 909 |
);
|
| 910 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').executable_routing_rules, true);
|
| 911 |
+
assert.deepEqual(body.layers.find((layer) => layer.name === 'capabilities').request_modes_supported, [
|
| 912 |
+
'images-non-stream',
|
| 913 |
+
'images-sse',
|
| 914 |
+
'responses-non-stream',
|
| 915 |
+
'responses-sse'
|
| 916 |
+
]);
|
| 917 |
+
assert.deepEqual(body.layers.find((layer) => layer.name === 'runtime_backend').effective_request_modes, [
|
| 918 |
+
'images-non-stream',
|
| 919 |
+
'images-sse'
|
| 920 |
+
]);
|
| 921 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_declared_supported, true);
|
| 922 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_required, true);
|
| 923 |
assert.equal(body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_ready, false);
|
| 924 |
assert.equal(body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').declared_supported, true);
|
| 925 |
+
assert.match(
|
| 926 |
+
body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').real_smoke_gate,
|
| 927 |
+
/gpt2image-responses-sse/
|
| 928 |
+
);
|
| 929 |
+
assert.deepEqual(
|
| 930 |
+
body.layers.find((layer) => layer.name === 'responses_gpt2image_readiness').real_smoke_gates,
|
| 931 |
+
[
|
| 932 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-responses-json --allow-billable',
|
| 933 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
|
| 934 |
+
]
|
| 935 |
+
);
|
| 936 |
assert.match(
|
| 937 |
body.layers.find((layer) => layer.name === 'capabilities').page_sse_auth_next_action,
|
| 938 |
/GPT_IMAGE_APP_PASSWORD_HASH/
|
|
|
|
| 1066 |
assert.equal(body.interactive_confirmation_required, false);
|
| 1067 |
assert.equal(body.summary.page_sse_real_smoke, 'passed');
|
| 1068 |
assert.equal(body.summary.responses_page_sse_generate_smoke, 'passed');
|
| 1069 |
+
assert.equal(body.summary.responses_agent_generate_smoke, 'passed');
|
| 1070 |
assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
|
| 1071 |
assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'passed');
|
| 1072 |
+
assert.equal(body.summary.real_smoke_checks.responses_agent_generate_1k, 'passed');
|
| 1073 |
+
assert.equal(body.summary.request_modes.smoke['images-non-stream'].state, 'passed');
|
| 1074 |
+
assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'passed');
|
| 1075 |
+
assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'passed');
|
| 1076 |
assert.equal(body.summary.real_smoke_checks.agent_edit_1k, 'skipped');
|
| 1077 |
assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
|
| 1078 |
assert.ok(hits.includes('/api/agent/images/generate'));
|
|
|
|
| 1159 |
assert.equal(body.summary.billable_smoke, 'failed');
|
| 1160 |
assert.equal(body.summary.page_sse_real_smoke, 'failed');
|
| 1161 |
assert.equal(body.summary.responses_page_sse_generate_smoke, 'failed');
|
| 1162 |
+
assert.equal(body.summary.responses_agent_generate_smoke, 'passed');
|
| 1163 |
assert.equal(body.summary.real_smoke_checks.agent_generate_1k, 'passed');
|
| 1164 |
assert.equal(body.summary.real_smoke_checks.responses_page_sse_generate_1k, 'failed');
|
| 1165 |
+
assert.equal(body.summary.real_smoke_checks.responses_agent_generate_1k, 'passed');
|
| 1166 |
+
assert.equal(body.summary.request_modes.smoke['images-non-stream'].state, 'passed');
|
| 1167 |
+
assert.equal(body.summary.request_modes.smoke['responses-non-stream'].state, 'passed');
|
| 1168 |
+
assert.equal(body.summary.request_modes.smoke['responses-sse'].state, 'failed');
|
| 1169 |
assert.equal(body.summary.real_smoke_checks.page_sse_edit_2k, 'skipped');
|
| 1170 |
const pageSseOutput = body.layers
|
| 1171 |
.find((layer) => layer.name === 'billable_smoke')
|
scripts/smoke-image-upstream-compat.mjs
CHANGED
|
@@ -297,7 +297,7 @@ async function main() {
|
|
| 297 |
const { POST } = await import('../src/app/api/images/route.ts');
|
| 298 |
try {
|
| 299 |
for (const testCase of cases) await runCase(POST, testCase);
|
| 300 |
-
console.log('image upstream compatibility
|
| 301 |
} finally {
|
| 302 |
restoreProcessEnv();
|
| 303 |
}
|
|
|
|
| 297 |
const { POST } = await import('../src/app/api/images/route.ts');
|
| 298 |
try {
|
| 299 |
for (const testCase of cases) await runCase(POST, testCase);
|
| 300 |
+
console.log('image upstream compatibility local fixture smoke passed');
|
| 301 |
} finally {
|
| 302 |
restoreProcessEnv();
|
| 303 |
}
|
scripts/smoke-image-upstream-real.mjs
CHANGED
|
@@ -68,6 +68,14 @@ const SERVER_CHANNEL_CASES = [
|
|
| 68 |
backend: 'responses-image-generation',
|
| 69 |
serverChannel: true,
|
| 70 |
endpoint: 'agent-generate'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
}
|
| 72 |
];
|
| 73 |
|
|
|
|
| 68 |
backend: 'responses-image-generation',
|
| 69 |
serverChannel: true,
|
| 70 |
endpoint: 'agent-generate'
|
| 71 |
+
},
|
| 72 |
+
{
|
| 73 |
+
id: 'server-channel-agent-responses-json',
|
| 74 |
+
prefix: 'IMAGE_REAL_SMOKE_SERVER',
|
| 75 |
+
stream: false,
|
| 76 |
+
backend: 'responses-image-generation',
|
| 77 |
+
serverChannel: true,
|
| 78 |
+
endpoint: 'agent-generate'
|
| 79 |
}
|
| 80 |
];
|
| 81 |
|
scripts/status.mjs
CHANGED
|
@@ -5,24 +5,31 @@ import net from 'node:net';
|
|
| 5 |
|
| 6 |
import { HF_SPACE_ID, HF_SPACE_URL } from './hf-space-doctor-utils.mjs';
|
| 7 |
import { isMainModule, parseJsonPayload, printJson, runCommand, runCommandStrict } from './command-center-utils.mjs';
|
|
|
|
| 8 |
|
| 9 |
const REMOTE_STATUS_TIMEOUT_MS = 30_000;
|
| 10 |
const LOCAL_ENDPOINT_TIMEOUT_MS = 500;
|
| 11 |
const IMAGE_UPSTREAM_REAL_SMOKE_CASES = [
|
| 12 |
-
{ id: 'original-images-json', prefix: 'IMAGE_REAL_SMOKE_ORIGINAL' },
|
| 13 |
-
{ id: 'gaoren-images-sse', prefix: 'IMAGE_REAL_SMOKE_GAOREN' },
|
| 14 |
-
{ id: 'sub2api-images-sse', prefix: 'IMAGE_REAL_SMOKE_SUB2API' },
|
| 15 |
{
|
| 16 |
id: 'sub2api-responses-json',
|
| 17 |
prefix: 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES',
|
| 18 |
fallbackPrefix: 'IMAGE_REAL_SMOKE_SUB2API',
|
| 19 |
-
requiresResponsesModel: true
|
|
|
|
| 20 |
},
|
| 21 |
-
{
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
];
|
| 24 |
const IMAGE_UPSTREAM_FINAL_GATE_COMMAND =
|
| 25 |
-
|
| 26 |
const STATUS_ENV_FILES = [
|
| 27 |
{ path: '.env.local', override: false },
|
| 28 |
{ path: '.env.real-smoke.local', override: true }
|
|
@@ -152,6 +159,7 @@ export function buildImageUpstreamRealSmokeStatus(env = process.env) {
|
|
| 152 |
const missingEnvAny = readMissingEnvAny(testCase, target);
|
| 153 |
return {
|
| 154 |
id: testCase.id,
|
|
|
|
| 155 |
configured: missingEnvAny.length === 0 && target.invalidEnv.length === 0,
|
| 156 |
...(missingEnvAny.length > 0 ? { missing_env_any: missingEnvAny } : {}),
|
| 157 |
...(target.invalidEnv.length > 0 ? { invalid_env: target.invalidEnv } : {})
|
|
@@ -174,10 +182,37 @@ export function buildImageUpstreamRealSmokeStatus(env = process.env) {
|
|
| 174 |
invalid_count: invalidCases.length,
|
| 175 |
invalid_cases: invalidCases.map((item) => item.id),
|
| 176 |
invalid_env: Object.fromEntries(invalidCases.map((item) => [item.id, item.invalid_env])),
|
|
|
|
| 177 |
final_gate_command: IMAGE_UPSTREAM_FINAL_GATE_COMMAND
|
| 178 |
};
|
| 179 |
}
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
function normalizeLocalHostname(hostname) {
|
| 182 |
return hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
| 183 |
}
|
|
|
|
| 5 |
|
| 6 |
import { HF_SPACE_ID, HF_SPACE_URL } from './hf-space-doctor-utils.mjs';
|
| 7 |
import { isMainModule, parseJsonPayload, printJson, runCommand, runCommandStrict } from './command-center-utils.mjs';
|
| 8 |
+
import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_ADMIN_CONTROL } from '../src/lib/channel-request-mode-values.mjs';
|
| 9 |
|
| 10 |
const REMOTE_STATUS_TIMEOUT_MS = 30_000;
|
| 11 |
const LOCAL_ENDPOINT_TIMEOUT_MS = 500;
|
| 12 |
const IMAGE_UPSTREAM_REAL_SMOKE_CASES = [
|
| 13 |
+
{ id: 'original-images-json', prefix: 'IMAGE_REAL_SMOKE_ORIGINAL', requestMode: 'images-non-stream' },
|
| 14 |
+
{ id: 'gaoren-images-sse', prefix: 'IMAGE_REAL_SMOKE_GAOREN', requestMode: 'images-sse' },
|
| 15 |
+
{ id: 'sub2api-images-sse', prefix: 'IMAGE_REAL_SMOKE_SUB2API', requestMode: 'images-sse' },
|
| 16 |
{
|
| 17 |
id: 'sub2api-responses-json',
|
| 18 |
prefix: 'IMAGE_REAL_SMOKE_SUB2API_RESPONSES',
|
| 19 |
fallbackPrefix: 'IMAGE_REAL_SMOKE_SUB2API',
|
| 20 |
+
requiresResponsesModel: true,
|
| 21 |
+
requestMode: 'responses-non-stream'
|
| 22 |
},
|
| 23 |
+
{
|
| 24 |
+
id: 'gpt2image-responses-sse',
|
| 25 |
+
prefix: 'IMAGE_REAL_SMOKE_GPT2IMAGE',
|
| 26 |
+
requiresResponsesModel: true,
|
| 27 |
+
requestMode: 'responses-sse'
|
| 28 |
+
},
|
| 29 |
+
{ id: 'matsca-images-sse', prefix: 'IMAGE_REAL_SMOKE_MATSCA', requestMode: 'images-sse' }
|
| 30 |
];
|
| 31 |
const IMAGE_UPSTREAM_FINAL_GATE_COMMAND =
|
| 32 |
+
CHANNEL_REQUEST_MODE_ADMIN_CONTROL.finalGateCommand;
|
| 33 |
const STATUS_ENV_FILES = [
|
| 34 |
{ path: '.env.local', override: false },
|
| 35 |
{ path: '.env.real-smoke.local', override: true }
|
|
|
|
| 159 |
const missingEnvAny = readMissingEnvAny(testCase, target);
|
| 160 |
return {
|
| 161 |
id: testCase.id,
|
| 162 |
+
request_mode: testCase.requestMode,
|
| 163 |
configured: missingEnvAny.length === 0 && target.invalidEnv.length === 0,
|
| 164 |
...(missingEnvAny.length > 0 ? { missing_env_any: missingEnvAny } : {}),
|
| 165 |
...(target.invalidEnv.length > 0 ? { invalid_env: target.invalidEnv } : {})
|
|
|
|
| 182 |
invalid_count: invalidCases.length,
|
| 183 |
invalid_cases: invalidCases.map((item) => item.id),
|
| 184 |
invalid_env: Object.fromEntries(invalidCases.map((item) => [item.id, item.invalid_env])),
|
| 185 |
+
request_modes: summarizeRealSmokeRequestModes(caseSummaries),
|
| 186 |
final_gate_command: IMAGE_UPSTREAM_FINAL_GATE_COMMAND
|
| 187 |
};
|
| 188 |
}
|
| 189 |
|
| 190 |
+
function summarizeRealSmokeRequestModes(caseSummaries) {
|
| 191 |
+
return Object.fromEntries(
|
| 192 |
+
CHANNEL_REQUEST_MODES.map((mode) => {
|
| 193 |
+
const items = caseSummaries.filter((item) => item.request_mode === mode);
|
| 194 |
+
const configured = items.filter((item) => item.configured);
|
| 195 |
+
const missing = items.filter((item) => Array.isArray(item.missing_env_any) && item.missing_env_any.length > 0);
|
| 196 |
+
const invalid = items.filter((item) => Array.isArray(item.invalid_env) && item.invalid_env.length > 0);
|
| 197 |
+
return [
|
| 198 |
+
mode,
|
| 199 |
+
{
|
| 200 |
+
required_count: items.length,
|
| 201 |
+
required_cases: items.map((item) => item.id),
|
| 202 |
+
configuration_complete: missing.length === 0 && invalid.length === 0,
|
| 203 |
+
configured_count: configured.length,
|
| 204 |
+
configured_cases: configured.map((item) => item.id),
|
| 205 |
+
missing_count: missing.length,
|
| 206 |
+
missing_cases: missing.map((item) => item.id),
|
| 207 |
+
invalid_count: invalid.length,
|
| 208 |
+
invalid_cases: invalid.map((item) => item.id),
|
| 209 |
+
smoke_state: 'not_run_by_status'
|
| 210 |
+
}
|
| 211 |
+
];
|
| 212 |
+
})
|
| 213 |
+
);
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
function normalizeLocalHostname(hostname) {
|
| 217 |
return hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
| 218 |
}
|
skills/gpt-image-playground-agent/SKILL.md
CHANGED
|
@@ -32,6 +32,17 @@ Agent API 只作为自动化客户端接口,不作为首战场景或用户验
|
|
| 32 |
## 路由规则
|
| 33 |
|
| 34 |
- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和 job polling。Agent 客户端不要按尺寸、远端 HTTPS 或流式策略自行选择 `/api/images`、`/api/agent/images/generate` 或 job endpoint。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
- 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。
|
| 36 |
- `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
|
| 37 |
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
|
|
@@ -87,7 +98,7 @@ Authorization: Bearer <token>
|
|
| 87 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 88 |
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。
|
| 89 |
- 不要直接调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`,并且本次是显式 `--job` 诊断或兼容场景。默认 generate 使用 `orchestration.endpoint`。
|
| 90 |
-
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope` 和 `retry_after_seconds`。
|
| 91 |
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
| 92 |
- 不要把 `/api/runtime-capabilities`、`/api/feedback`、`/api/shares`、`/api/logs` 或 `/api/image-delete` 当成 Agent API。它们是页面运行态或页面工作流端点,鉴权和字段契约与 `/api/agent/*` ��同;反馈和诊断的 Agent 只读入口是 `/api/agent/page-requests/{id}/feedback`、`/api/agent/page-requests/feedback`、`/api/agent/diagnostics/page-requests/{id}` 和 `/api/agent/diagnostics/page-requests`。
|
| 93 |
|
|
@@ -114,7 +125,7 @@ Authorization: Bearer <token>
|
|
| 114 |
- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
|
| 115 |
- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 116 |
|
| 117 |
-
生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成 dry-run 默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`,便于人工审查。
|
| 118 |
所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
|
| 119 |
|
| 120 |
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
|
@@ -141,7 +152,10 @@ Authorization: Bearer <token>
|
|
| 141 |
| `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
|
| 142 |
| `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
|
| 143 |
| `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 144 |
-
| `summary.
|
|
|
|
|
|
|
|
|
|
| 145 |
| `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
|
| 146 |
| `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
|
| 147 |
| `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
|
|
|
|
| 32 |
## 路由规则
|
| 33 |
|
| 34 |
- 先读取 `GET /api/agent/capabilities` 的 `orchestration` 与 `routing_rules`。普通文生图默认提交业务意图到 `orchestration.endpoint`,当前为 `POST /api/agent/image-requests`;服务端负责选择内部执行路径、上游策略和 job polling。Agent 客户端不要按尺寸、远端 HTTPS 或流式策略自行选择 `/api/images`、`/api/agent/images/generate` 或 job endpoint。
|
| 35 |
+
- `capabilities.supported.request_modes`、`capabilities.upstream_request_headers.channels[].request_modes` 和 `capabilities.request_mode_controls` 是服务端管理员配置的渠道请求方式白名单与诊断控制面;Agent 客户端不要据此绕过 `orchestration.endpoint` 自行挑选 Images、Responses、SSE 或非流式路径。
|
| 36 |
+
|
| 37 |
+
执行决策表:
|
| 38 |
+
|
| 39 |
+
| 场景 | Agent 输入 | 服务端职责 | 结果字段 |
|
| 40 |
+
| --- | --- | --- | --- |
|
| 41 |
+
| 普通文生图 | prompt、尺寸、质量等业务意图 | 通过 `orchestration.endpoint` 选择 Agent/job、渠道、Images/Responses、SSE/非流式 | `summary.transport`、`summary.route_mode`、`summary.channel_request_mode`、`summary.route_decision` |
|
| 42 |
+
| 自动上游流式 | `stream_mode=auto` 或默认值 | 若 SSE 渠道不可用,可在服务端显式退到非流式并标记 fallback | `summary.channel_request_mode_fallback_applied=true` |
|
| 43 |
+
| 显式流式诊断 | `stream_mode=stream` 或显式 `--page-sse` | 失败必须显式返回错误,不静默改成非流式 | `summary.route_decision.no_channel_reason` 或结构化错误 |
|
| 44 |
+
| 管理员渠道白名单 | `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES` | 只约束服务端可选渠道,不授权 Agent 客户端自选 endpoint | `capabilities.request_mode_controls`、`agent:doctor.summary.request_modes` |
|
| 45 |
+
|
| 46 |
- 默认 WebP edit 使用页面端 `POST /api/images` form-data SSE 路径,因为 Agent edit 不接收输出格式字段。需要 Responses image_generation edit 时也必须使用页面 SSE,不要用 `--agent`。显式 `--agent` 才使用 `/api/agent/images/edit` Agent multipart 最终 JSON,输出格式固定为 Agent 契约;如果页面流式不可用或失败,先诊断结构化错误,再用新的 `Idempotency-Key` 显式决定是否用 Agent edit 对照。Agent edit 只是对照路径,不保证与页面 SSE 的输出格式和像素尺寸完全一致;尺寸敏感任务必须用 `--dimension-check` 或下载后校验。
|
| 47 |
- `capabilities` 里声明的 `page_sse_supported=true`、`agent_streaming.upstream_sse.supported=true` 只表示路径被声明支持,不表示当前渠道每次实测都能成功;如果页面 SSE、Responses 路径或服务端编排入口返回 `503`、断流,或 `summary` 里 `selected_channel_id`、`upstream_host` 为空,先诊断结构化错误,再用新的 `Idempotency-Key` 显式选择诊断路径,不自动回退。
|
| 48 |
- 复杂 UI 批量出图优先使用页面端 `POST /api/images` SSE 和 `scripts/batch-images.mjs`;不要手动并行启动多个单张脚本,因为这会绕过 manifest、`--resume`、`capacity_feedback` 和尺寸门禁。需要并发时显式设置 `--concurrency N` 或页面“并发批量”开关,并记录切换原因、失败清单和续跑锚点。
|
|
|
|
| 98 |
- 不要对同一个已进入终态 `failed` 的 `Idempotency-Key` 继续重试。终态失败回放会返回 `retryable=false`;需要重新尝试时,先确认失败原因,再创建新的业务操作和新的 `Idempotency-Key`。
|
| 99 |
- 不要把 `agent_streaming.page_sse.supported=true` 解读为 `/api/agent/images/generate` 会对客户端返回 SSE;Agent generate/edit 对外仍是最终 JSON。`agent_streaming.upstream_sse` 仅表示服务端内部可消费上游 SSE 并保存最终 artifact。
|
| 100 |
- 不要直接调用 job endpoints,除非 capabilities 明确返回 `agent_jobs.supported=true` 且 `mode=job_polling`,并且本次是显式 `--job` 诊断或兼容场景。默认 generate 使用 `orchestration.endpoint`。
|
| 101 |
+
- 不要把一次高分辨率、高质量长耗时失败归纳为全局不可用。优先查看 `error.diagnostics.upstream_status`、`upstream_event_type`、`partial_image_count`、`transport_error`、`selected_channel_id`、`channel_cooldown_scope`、`error.diagnostics.cooldown_target.request_mode` 和 `retry_after_seconds`。
|
| 102 |
- 不要在 `error.retryable=false` 时依据历史 `retry_after_seconds` 继续重试同一个 key;终态失败需要新业务操作和新 key。
|
| 103 |
- 不要把 `/api/runtime-capabilities`、`/api/feedback`、`/api/shares`、`/api/logs` 或 `/api/image-delete` 当成 Agent API。它们是页面运行态或页面工作流端点,鉴权和字段契约与 `/api/agent/*` ��同;反馈和诊断的 Agent 只读入口是 `/api/agent/page-requests/{id}/feedback`、`/api/agent/page-requests/feedback`、`/api/agent/diagnostics/page-requests/{id}` 和 `/api/agent/diagnostics/page-requests`。
|
| 104 |
|
|
|
|
| 125 |
- `scripts/diagnose-request.mjs`:按一个或多个页面 `clientRequestId` 只读查询结果反馈和脱敏日志诊断摘要,也可按 Agent `request_id` 或 `idempotency_key` 查询 Agent state 请求诊断;支持读取批量 manifest 和 `--base-url`,不触发生图计费。
|
| 126 |
- `scripts/probe-upstream-image.mjs`:直接探测上游图片接口连通性。默认只检查 DNS、TLS 和 `/models`,必须添加 `--allow-billable` 才会真实调用 `/images/generations`。
|
| 127 |
|
| 128 |
+
生成、编辑和批量脚本的 dry-run 输出会包含 `verification_scope.mode=local_planning_only`,表示只验证了本地请求构造、参数归一化和静态路由规划;它不会读取远端 capabilities,不会验证远端鉴权、渠道容量或 manifest 写入。生成 dry-run 默认 `routing_guidance.transport=server_orchestrated`,表示真实请求只提交业务意图到服务端编排入口;显式 `--agent`、`--job`、`--page-sse` 才会显示对应诊断路径。批量 dry-run 还会包含 `guardrails`,提示真实执行要复用同一个 `--ordered-prefix`,固定尺寸任务是否建议加 `--dimension-check`。真实执行输出会包含 `summary`;成功摘要含 `ok=true`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host` 和脱敏 `request_headers`。失败摘要含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`;尺寸门禁失败时还会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`,便于人工审查。
|
| 129 |
所有生成、编辑、批量和探针脚本在 dry-run 或真实请求前都会校验尺寸参数。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量页面 SSE 和上游探针默认请求 `output_format=webp`、`output_compression=100`;普通 Agent edit 不发送输出格式字段,输出格式固定为 Agent 契约。
|
| 130 |
|
| 131 |
如果当前上下文位于仓库根目录,管理员侧优先使用顶层命令:
|
|
|
|
| 152 |
| `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
|
| 153 |
| `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
|
| 154 |
| `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 155 |
+
| `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 156 |
+
| `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
|
| 157 |
+
| `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
|
| 158 |
+
| `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
|
| 159 |
| `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
|
| 160 |
| `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
|
| 161 |
| `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
|
skills/gpt-image-playground-agent/references/api.md
CHANGED
|
@@ -47,7 +47,7 @@ npm run env:summary -- --file .env.local --container gpt-image-playground-custom
|
|
| 47 |
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 48 |
页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
|
| 49 |
脚本会在 dry-run 和真实请求前前置校验 `--size` 或 JSONL `size`。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量和上游探针默认请求 `output_format=webp`、`output_compression=100`。
|
| 50 |
-
真实执行输出会包含机器可读 `summary`。成功摘要包含 `ok`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。失败摘要也稳定包含空数组或 `null` 形式的产物、路由、渠道和尺寸字段,便于 subagent 按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`。Agent JSON 失败时脚本会按幂等键只读查询 Agent state;若命中,会把 `request_id`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。
|
| 51 |
|
| 52 |
生成脚本参数:
|
| 53 |
|
|
@@ -204,8 +204,10 @@ GET /api/agent/capabilities
|
|
| 204 |
- `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
|
| 205 |
- `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
|
| 206 |
- 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
|
|
|
|
| 207 |
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
|
| 208 |
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
|
|
|
|
| 209 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
| 210 |
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
| 211 |
- `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
|
|
@@ -571,7 +573,10 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 571 |
| `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
|
| 572 |
| `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
|
| 573 |
| `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 574 |
-
| `summary.
|
|
|
|
|
|
|
|
|
|
| 575 |
| `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
|
| 576 |
| `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
|
| 577 |
| `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
|
|
@@ -683,7 +688,8 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 683 |
"retry_after_ms": 15000,
|
| 684 |
"cooldown_until": "2026-05-20T00:00:15.000Z",
|
| 685 |
"cooldown_target": {
|
| 686 |
-
"channel_id": "default"
|
|
|
|
| 687 |
},
|
| 688 |
"channel_cooldown_scope": "channel",
|
| 689 |
"response_headers": {
|
|
@@ -696,7 +702,7 @@ node "<skill-root>/scripts/diagnose-request.mjs" --base-url https://your-space.h
|
|
| 696 |
}
|
| 697 |
```
|
| 698 |
|
| 699 |
-
`diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。如果页面 SSE 请求返回 `page_sse_failed`、`503`、断流,且 `summary.selected_channel_id` 与 `summary.upstream_host` 为空,按页面流式路径未跑通处理;先用 `diagnose-request.mjs` 读取结构化摘要,再用新的 `Idempotency-Key` 显式选择 Agent JSON 或 job,不自动回退。
|
| 700 |
|
| 701 |
常见错误码:
|
| 702 |
|
|
|
|
| 47 |
同一个 `Idempotency-Key` 如果已经进入终态 `failed`,再次调用 generate/edit 或 job result/status 只会回放该失败,且 `retryable=false`。需要重新尝试时应创建新的业务操作和新的 `Idempotency-Key`。
|
| 48 |
页面端 `/api/images` SSE 会把同一个业务 key 复用到 `clientRequestId`,因此脚本使用的 `Idempotency-Key` 不能超过 capabilities 中 `agent_streaming.page_sse.client_request_id.max_length` 声明的字符数;超长时会直接报错,不会静默截断。
|
| 49 |
脚本会在 dry-run 和真实请求前前置校验 `--size` 或 JSONL `size`。`gpt-image-2` 支持 `auto` 或任意正整数 `WIDTHxHEIGHT`;默认 OpenAI-compatible 上游的更严格尺寸边界由服务端 profile 或真实上游显式报错。非 `gpt-image-2` 模型只接受 `auto`、`1024x1024`、`1536x1024` 或 `1024x1536`。生成、页面编辑、批量和上游探针默认请求 `output_format=webp`、`output_compression=100`。
|
| 50 |
+
真实执行输出会包含机器可读 `summary`。成功摘要包含 `ok`、`billable`、`request_id`、`idempotency_key`、`artifact_ids`、`content_urls`、`absolute_content_urls`、`image_dimensions`、`actual_dimensions`、`cached`、`started_at`、`completed_at`、`elapsed_ms`、`server_elapsed_ms`、`elapsed_source`、`elapsed_breakdown`、`transport`、`endpoint`、`route_mode`、`image_backend`、`stream_mode`、`streaming_strategy`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、脱敏 `request_headers` 和 `next_action`。`transport` 表示 Agent 对外访问的服务端端点形态,`route_mode` 表示 Agent/job/page SSE 路径,`channel_request_mode` 表示服务端实际调用上游的 Images/Responses 与 SSE/非流式组合,`route_decision` 记录 requested backend、preferred/fallback/selected request mode、fallback 是否发生、选中渠道、上游 host 或 no-channel 原因。失败摘要也稳定包含空数组或 `null` 形式的产物、路由、渠道和尺寸字段,便于 subagent 按同一模板汇报;尺寸门禁失败属于“上游已生成但本地验收失败”,失败摘要会保留已生成产物的 `artifact_ids`、`content_urls`、`absolute_content_urls` 和 `image_dimensions`。失败摘要还包含 `route_decision`、`transport_error_kind`、`retry_after_ms`、`cooldown_until`、`cooldown_target`、`retryable`、`dimension_check_failed`、`expected_dimensions`、`actual_dimensions`、`agent_diagnostics_checked`、`agent_diagnostics_found`、`agent_diagnostics_unavailable_reason`、`agent_diagnostics_http_status` 和 `next_action`。Agent JSON 失败时脚本会按幂等键只读查询 Agent state;若命中,会把 `request_id`、`channel_request_mode`、`channel_request_mode_fallback_applied`、`route_decision`、`selected_channel_id`、`upstream_host`、`transport_error_kind` 合并进首次失败摘要,并输出 `agent_failure_diagnostics`。回答耗时问题时优先读取 `summary.elapsed_ms`;需要区分脚本等待和上游耗时时读取 `summary.elapsed_breakdown`。
|
| 51 |
|
| 52 |
生成脚本参数:
|
| 53 |
|
|
|
|
| 204 |
- `agent_streaming.page_sse.auth`:页面 SSE 的独立表单鉴权。`APP_PASSWORD` 已配置时为 `required=true`、`schemes=["form-password-hash"]`、`form_field="passwordHash"`。
|
| 205 |
- `agent_streaming.page_sse.client_request_id`:页面 SSE 的请求 ID 契约。脚本会把 `Idempotency-Key` 写入 form-data `clientRequestId`,最大长度以 `max_length` 为准,当前为 `128`。
|
| 206 |
- 页面 SSE 或 Responses 路径失败时,如果 `selected_channel_id`、`upstream_host` 为空,通常表示请求没有真正落到可执行渠道;先诊断结构化错误,再用新的 `Idempotency-Key` 显式改路由。
|
| 207 |
+
- `supported.request_modes`:服务端支持的上游请求方式枚举,当前为 `images-non-stream`、`images-sse`、`responses-non-stream`、`responses-sse`。该字段描述服务端能力全集,不代表每个管理员渠道都已真实 smoke 通过。
|
| 208 |
- `upstream_request_headers.default`:默认上游请求头摘要,包含 `user_agent_effective`、`has_extra_headers`、`allowed_header_names` 和 `configured_header_names`。
|
| 209 |
- `upstream_request_headers.channels`:每个服务端渠道的脱敏请求头摘要。该字段不包含 API key、Authorization 值、Matsca app secret 值或任意 header value。
|
| 210 |
+
- `request_mode_controls`:管理员 request mode 白名单控制面,声明 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`;Agent 客户端只能用于解释执行结果,不应据此自行选择上游请求方式。
|
| 211 |
- `routing_rules.high_resolution_edit`:`edit` 且最大边大于 `2048` 时默认优先使用页面端 `/api/images` SSE,页面流式有问题时显式回退。
|
| 212 |
- `routing_rules.complex_ui_batch`:复杂 UI 批量出图推荐使用页面端 `/api/images` SSE。
|
| 213 |
- `routing_rules.long_image_recovery`:长图恢复或续跑锚点场景推荐使用页面端 `/api/images` SSE。
|
|
|
|
| 573 |
| `responses_image_backend_real_smoke_status` | `first-run --json` | 结构化说明 `first-run` 未执行真实 Responses image_generation smoke;不要把声明支持当作实测通过。 |
|
| 574 |
| `summary.page_sse_real_smoke` | `agent:doctor` | Page SSE 真实 smoke 的兼容聚合状态;任一 Page SSE smoke 失败为 `failed`,任一通过且无失败为 `passed`,全部跳过为 `skipped`;精确判断优先看 `summary.real_smoke_checks`。 |
|
| 575 |
| `summary.responses_page_sse_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + page SSE + `responses-sse` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 576 |
+
| `summary.responses_agent_generate_smoke` | `agent:doctor` | `--allow-billable` 时对 `responses-image-generation` + Agent JSON + `responses-non-stream` 这条文生图路径的真实 smoke 状态;非计费时为 `skipped`。 |
|
| 577 |
+
| `summary.real_smoke_checks` | `agent:doctor` | 各真实 smoke 的状态汇总,包含 `agent_generate_1k`、`responses_page_sse_generate_1k`、`responses_agent_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
|
| 578 |
+
| `summary.request_modes` | `agent:doctor` | 管理员 request mode 的配置和真实 smoke 摘要;`billable=false` 时只能证明配置可见,不能当作真实上游通过。 |
|
| 579 |
+
| `request_mode_controls` | `capabilities` | 管理员 request mode 白名单控制面;包含 `OPENAI_UPSTREAM_REQUEST_MODES`、`OPENAI_CHANNEL_N_REQUEST_MODES`、真实 smoke gate 和 `agent_client_policy=diagnostics_only`。 |
|
| 580 |
| `private_agent_env.exists` | `first-run --json` | 本机是否存在 `.env.agent.local` 私有配置;Agent CLI 默认从当前仓库根目录读取该文件。 |
|
| 581 |
| `capabilities.ok` | `first-run --json`、`agent:doctor` | 目标地址是否返回 Agent capabilities;失败时先看 HTTP 状态、鉴权提示和服务地址。 |
|
| 582 |
| `diagnostics_retention` | `diagnose-request.mjs` | 页面日志诊断的保留窗口;无匹配日志不等于请求一定没发生。 |
|
|
|
|
| 688 |
"retry_after_ms": 15000,
|
| 689 |
"cooldown_until": "2026-05-20T00:00:15.000Z",
|
| 690 |
"cooldown_target": {
|
| 691 |
+
"channel_id": "default",
|
| 692 |
+
"request_mode": "images-sse"
|
| 693 |
},
|
| 694 |
"channel_cooldown_scope": "channel",
|
| 695 |
"response_headers": {
|
|
|
|
| 702 |
}
|
| 703 |
```
|
| 704 |
|
| 705 |
+
`diagnostics` 只包含脱敏诊断字段和白名单响应头,不包含 API key、token、完整上游响应体或图片 base64。SDK/网络层只有 `Connection error.` 时,`transport_error` 会是 `true`,但不会伪造 `upstream_status`。`diagnostics.route_decision` 与成功响应的 `execution.route_decision` 同口径,用于解释服务端为何选择或未能选择某个上游请求方式。如果页面 SSE 请求返回 `page_sse_failed`、`503`、断流,且 `summary.selected_channel_id` 与 `summary.upstream_host` 为空,按页面流式路径未跑通处理;先用 `diagnose-request.mjs` 读取结构化摘要,再用新的 `Idempotency-Key` 显式选择 Agent JSON 或 job,不自动回退。
|
| 706 |
|
| 707 |
常见错误码:
|
| 708 |
|
skills/gpt-image-playground-agent/scripts/lib/agent-diagnostics-summary.mjs
CHANGED
|
@@ -130,9 +130,18 @@ function buildAgentFailureDiagnostics(result, diagnostics) {
|
|
| 130 |
status: readString(request?.status),
|
| 131 |
error_code: readString(error?.code),
|
| 132 |
retryable: typeof error?.retryable === 'boolean' ? error.retryable : undefined,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
selected_channel_id: readString(errorDiagnostics?.selected_channel_id) || readString(execution?.selected_channel_id),
|
| 134 |
upstream_host: readString(errorDiagnostics?.upstream_host) || readString(execution?.upstream_host),
|
| 135 |
-
transport_error_kind: readString(errorDiagnostics?.transport_error_kind)
|
|
|
|
|
|
|
|
|
|
| 136 |
};
|
| 137 |
}
|
| 138 |
|
|
@@ -147,11 +156,27 @@ function mergeDiagnosticsIntoSummary(summary, diagnosticsResult, diagnostics) {
|
|
| 147 |
return {
|
| 148 |
...summary,
|
| 149 |
request_id: preferExistingString(summary.request_id, request?.request_id) || null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
selected_channel_id:
|
| 151 |
preferExistingString(summary.selected_channel_id, errorDiagnostics?.selected_channel_id, execution?.selected_channel_id) ||
|
| 152 |
null,
|
| 153 |
upstream_host: preferExistingString(summary.upstream_host, errorDiagnostics?.upstream_host, execution?.upstream_host) || null,
|
| 154 |
transport_error_kind: preferExistingString(summary.transport_error_kind, errorDiagnostics?.transport_error_kind) || null,
|
|
|
|
|
|
|
|
|
|
| 155 |
retryable: diagnosticsRetryable ?? summary.retryable,
|
| 156 |
agent_diagnostics_checked: true,
|
| 157 |
agent_diagnostics_found: diagnosticsResult.found === true,
|
|
@@ -195,10 +220,22 @@ function isObject(value) {
|
|
| 195 |
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
| 196 |
}
|
| 197 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
function readString(value) {
|
| 199 |
return typeof value === 'string' && value ? value : undefined;
|
| 200 |
}
|
| 201 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
function readPositiveInteger(value) {
|
| 203 |
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
| 204 |
}
|
|
|
|
| 130 |
status: readString(request?.status),
|
| 131 |
error_code: readString(error?.code),
|
| 132 |
retryable: typeof error?.retryable === 'boolean' ? error.retryable : undefined,
|
| 133 |
+
channel_request_mode:
|
| 134 |
+
readString(errorDiagnostics?.channel_request_mode) || readString(execution?.channel_request_mode),
|
| 135 |
+
channel_request_mode_fallback_applied:
|
| 136 |
+
readBoolean(errorDiagnostics?.channel_request_mode_fallback_applied) ??
|
| 137 |
+
readBoolean(execution?.channel_request_mode_fallback_applied),
|
| 138 |
+
route_decision: readObject(errorDiagnostics?.route_decision) || readObject(execution?.route_decision),
|
| 139 |
selected_channel_id: readString(errorDiagnostics?.selected_channel_id) || readString(execution?.selected_channel_id),
|
| 140 |
upstream_host: readString(errorDiagnostics?.upstream_host) || readString(execution?.upstream_host),
|
| 141 |
+
transport_error_kind: readString(errorDiagnostics?.transport_error_kind),
|
| 142 |
+
retry_after_ms: readNonNegativeNumber(errorDiagnostics?.retry_after_ms),
|
| 143 |
+
cooldown_until: readString(errorDiagnostics?.cooldown_until),
|
| 144 |
+
cooldown_target: readObject(errorDiagnostics?.cooldown_target)
|
| 145 |
};
|
| 146 |
}
|
| 147 |
|
|
|
|
| 156 |
return {
|
| 157 |
...summary,
|
| 158 |
request_id: preferExistingString(summary.request_id, request?.request_id) || null,
|
| 159 |
+
channel_request_mode:
|
| 160 |
+
preferExistingString(summary.channel_request_mode, errorDiagnostics?.channel_request_mode, execution?.channel_request_mode) ||
|
| 161 |
+
null,
|
| 162 |
+
channel_request_mode_fallback_applied:
|
| 163 |
+
readBoolean(summary.channel_request_mode_fallback_applied) ??
|
| 164 |
+
readBoolean(errorDiagnostics?.channel_request_mode_fallback_applied) ??
|
| 165 |
+
readBoolean(execution?.channel_request_mode_fallback_applied) ??
|
| 166 |
+
null,
|
| 167 |
+
route_decision:
|
| 168 |
+
readObject(summary.route_decision) ||
|
| 169 |
+
readObject(errorDiagnostics?.route_decision) ||
|
| 170 |
+
readObject(execution?.route_decision) ||
|
| 171 |
+
null,
|
| 172 |
selected_channel_id:
|
| 173 |
preferExistingString(summary.selected_channel_id, errorDiagnostics?.selected_channel_id, execution?.selected_channel_id) ||
|
| 174 |
null,
|
| 175 |
upstream_host: preferExistingString(summary.upstream_host, errorDiagnostics?.upstream_host, execution?.upstream_host) || null,
|
| 176 |
transport_error_kind: preferExistingString(summary.transport_error_kind, errorDiagnostics?.transport_error_kind) || null,
|
| 177 |
+
retry_after_ms: readNonNegativeNumber(summary.retry_after_ms) ?? readNonNegativeNumber(errorDiagnostics?.retry_after_ms),
|
| 178 |
+
cooldown_until: preferExistingString(summary.cooldown_until, errorDiagnostics?.cooldown_until),
|
| 179 |
+
cooldown_target: readObject(summary.cooldown_target) || readObject(errorDiagnostics?.cooldown_target) || null,
|
| 180 |
retryable: diagnosticsRetryable ?? summary.retryable,
|
| 181 |
agent_diagnostics_checked: true,
|
| 182 |
agent_diagnostics_found: diagnosticsResult.found === true,
|
|
|
|
| 220 |
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
|
| 221 |
}
|
| 222 |
|
| 223 |
+
function readObject(value) {
|
| 224 |
+
return isObject(value) ? value : undefined;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
function readString(value) {
|
| 228 |
return typeof value === 'string' && value ? value : undefined;
|
| 229 |
}
|
| 230 |
|
| 231 |
+
function readBoolean(value) {
|
| 232 |
+
return typeof value === 'boolean' ? value : undefined;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
function readNonNegativeNumber(value) {
|
| 236 |
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
function readPositiveInteger(value) {
|
| 240 |
return Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
| 241 |
}
|
skills/gpt-image-playground-agent/scripts/lib/script-summary.mjs
CHANGED
|
@@ -42,6 +42,12 @@ export function buildSuccessSummary({ result, routing, timing, idempotencyKey, b
|
|
| 42 |
image_backend: readString(execution?.image_backend) || readString(routing?.image_backend) || null,
|
| 43 |
stream_mode: readString(execution?.stream_mode) || readString(routing?.stream_mode) || null,
|
| 44 |
streaming_strategy: readString(execution?.streaming_strategy) || readString(routing?.streaming_strategy) || null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
selected_channel_id: readString(execution?.selected_channel_id) || null,
|
| 46 |
upstream_host: readString(execution?.upstream_host) || null,
|
| 47 |
request_headers: readObject(execution?.request_headers),
|
|
@@ -82,6 +88,12 @@ export function buildFailureSummary({ errorBody, routing, timing, idempotencyKey
|
|
| 82 |
image_backend: readString(routing?.image_backend) || null,
|
| 83 |
stream_mode: readString(routing?.stream_mode) || null,
|
| 84 |
streaming_strategy: readString(routing?.streaming_strategy) || null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
selected_channel_id: readString(diagnostics?.selected_channel_id) || null,
|
| 86 |
upstream_host: readString(diagnostics?.upstream_host) || null,
|
| 87 |
transport_error_kind: readString(diagnostics?.transport_error_kind),
|
|
@@ -122,6 +134,9 @@ function stableSummary(value) {
|
|
| 122 |
image_backend: value.image_backend ?? null,
|
| 123 |
stream_mode: value.stream_mode ?? null,
|
| 124 |
streaming_strategy: value.streaming_strategy ?? null,
|
|
|
|
|
|
|
|
|
|
| 125 |
selected_channel_id: value.selected_channel_id ?? null,
|
| 126 |
upstream_host: value.upstream_host ?? null,
|
| 127 |
transport_error_kind: value.transport_error_kind ?? null,
|
|
|
|
| 42 |
image_backend: readString(execution?.image_backend) || readString(routing?.image_backend) || null,
|
| 43 |
stream_mode: readString(execution?.stream_mode) || readString(routing?.stream_mode) || null,
|
| 44 |
streaming_strategy: readString(execution?.streaming_strategy) || readString(routing?.streaming_strategy) || null,
|
| 45 |
+
channel_request_mode: readString(execution?.channel_request_mode) || null,
|
| 46 |
+
channel_request_mode_fallback_applied:
|
| 47 |
+
typeof execution?.channel_request_mode_fallback_applied === 'boolean'
|
| 48 |
+
? execution.channel_request_mode_fallback_applied
|
| 49 |
+
: null,
|
| 50 |
+
route_decision: readObject(execution?.route_decision) || null,
|
| 51 |
selected_channel_id: readString(execution?.selected_channel_id) || null,
|
| 52 |
upstream_host: readString(execution?.upstream_host) || null,
|
| 53 |
request_headers: readObject(execution?.request_headers),
|
|
|
|
| 88 |
image_backend: readString(routing?.image_backend) || null,
|
| 89 |
stream_mode: readString(routing?.stream_mode) || null,
|
| 90 |
streaming_strategy: readString(routing?.streaming_strategy) || null,
|
| 91 |
+
channel_request_mode: readString(diagnostics?.channel_request_mode) || null,
|
| 92 |
+
channel_request_mode_fallback_applied:
|
| 93 |
+
typeof diagnostics?.channel_request_mode_fallback_applied === 'boolean'
|
| 94 |
+
? diagnostics.channel_request_mode_fallback_applied
|
| 95 |
+
: null,
|
| 96 |
+
route_decision: readObject(diagnostics?.route_decision) || null,
|
| 97 |
selected_channel_id: readString(diagnostics?.selected_channel_id) || null,
|
| 98 |
upstream_host: readString(diagnostics?.upstream_host) || null,
|
| 99 |
transport_error_kind: readString(diagnostics?.transport_error_kind),
|
|
|
|
| 134 |
image_backend: value.image_backend ?? null,
|
| 135 |
stream_mode: value.stream_mode ?? null,
|
| 136 |
streaming_strategy: value.streaming_strategy ?? null,
|
| 137 |
+
channel_request_mode: value.channel_request_mode ?? null,
|
| 138 |
+
channel_request_mode_fallback_applied: value.channel_request_mode_fallback_applied ?? null,
|
| 139 |
+
route_decision: value.route_decision ?? null,
|
| 140 |
selected_channel_id: value.selected_channel_id ?? null,
|
| 141 |
upstream_host: value.upstream_host ?? null,
|
| 142 |
transport_error_kind: value.transport_error_kind ?? null,
|
src/app/api/agent/agent-routes.test.ts
CHANGED
|
@@ -37,6 +37,11 @@ beforeEach(async () => {
|
|
| 37 |
process.env.AGENT_STATE_BACKEND = 'sqlite';
|
| 38 |
process.env.AGENT_SQLITE_PATH = path.join(tempDir, 'agent.sqlite');
|
| 39 |
process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'fs';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
delete process.env.APP_PASSWORD;
|
| 41 |
delete process.env.AGENT_API_TOKEN;
|
| 42 |
delete process.env.OPENAI_API_KEY;
|
|
@@ -47,6 +52,7 @@ beforeEach(async () => {
|
|
| 47 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 48 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
| 49 |
delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE;
|
|
|
|
| 50 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID;
|
| 51 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET;
|
| 52 |
delete process.env.OPENAI_CHANNEL_1_USER_AGENT;
|
|
@@ -55,6 +61,7 @@ beforeEach(async () => {
|
|
| 55 |
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
|
| 56 |
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
|
| 57 |
delete process.env.OPENAI_CHANNEL_2_UPSTREAM_PROFILE;
|
|
|
|
| 58 |
delete process.env.OPENAI_CHANNEL_2_MATSCA_APP_ID;
|
| 59 |
delete process.env.OPENAI_CHANNEL_2_MATSCA_APP_SECRET;
|
| 60 |
delete process.env.OPENAI_CHANNEL_2_USER_AGENT;
|
|
@@ -135,6 +142,12 @@ describe('Agent route integration', () => {
|
|
| 135 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 136 |
{
|
| 137 |
id: 'matsca',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
request_headers: {
|
| 139 |
user_agent_effective: 'configured',
|
| 140 |
has_extra_headers: true,
|
|
@@ -203,6 +216,12 @@ describe('Agent route integration', () => {
|
|
| 203 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 204 |
{
|
| 205 |
id: 'matsca',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 206 |
request_headers: {
|
| 207 |
user_agent_effective: 'configured',
|
| 208 |
has_extra_headers: true,
|
|
@@ -249,6 +268,16 @@ describe('Agent route integration', () => {
|
|
| 249 |
assert.equal(firstBody.execution.image_backend, 'images-api');
|
| 250 |
assert.equal(firstBody.execution.stream_mode, 'non_stream');
|
| 251 |
assert.equal(firstBody.execution.streaming_strategy, 'auto');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
assert.equal(firstBody.execution.upstream_host, new URL(upstream.baseUrl).host);
|
| 253 |
assert.equal(firstBody.execution.request_headers.user_agent_effective, 'gpt-image-playground/2.1.0');
|
| 254 |
assert.equal(firstBody.execution.request_headers.has_extra_headers, false);
|
|
@@ -390,6 +419,9 @@ describe('Agent route integration', () => {
|
|
| 390 |
);
|
| 391 |
|
| 392 |
assert.equal(response.status, 200);
|
|
|
|
|
|
|
|
|
|
| 393 |
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 394 |
assert.equal(upstreamJson.stream, false);
|
| 395 |
assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
|
|
@@ -430,6 +462,87 @@ describe('Agent route integration', () => {
|
|
| 430 |
}
|
| 431 |
});
|
| 432 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 433 |
it('rejects OpenAI-compatible Agent generate profile violations before calling upstream', async () => {
|
| 434 |
const { generateImage } = await loadAgentRoutes();
|
| 435 |
let upstreamCalls = 0;
|
|
@@ -1027,6 +1140,7 @@ describe('Agent route integration', () => {
|
|
| 1027 |
assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
|
| 1028 |
assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel');
|
| 1029 |
assert.equal(body.error.diagnostics.cooldown_target.channel_id, 'default');
|
|
|
|
| 1030 |
assert.equal(typeof body.error.diagnostics.retry_after_ms, 'number');
|
| 1031 |
assert.equal(typeof body.error.diagnostics.cooldown_until, 'string');
|
| 1032 |
assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number');
|
|
@@ -1056,6 +1170,10 @@ describe('Agent route integration', () => {
|
|
| 1056 |
assert.equal(diagnosticsBody.diagnostics.error.diagnostics.selected_channel_id, 'default');
|
| 1057 |
assert.match(diagnosticsBody.diagnostics.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
|
| 1058 |
assert.equal(diagnosticsBody.diagnostics.error.diagnostics.cooldown_target.channel_id, 'default');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1059 |
assert.equal(typeof diagnosticsBody.diagnostics.error.diagnostics.retry_after_ms, 'number');
|
| 1060 |
assert.equal(JSON.stringify(diagnosticsBody).includes('test-key'), false);
|
| 1061 |
} finally {
|
|
@@ -1105,6 +1223,8 @@ describe('Agent route integration', () => {
|
|
| 1105 |
assert.equal(resultBody.execution.transport, 'agent_job_polling');
|
| 1106 |
assert.equal(resultBody.execution.endpoint, '/api/agent/jobs/images/generate');
|
| 1107 |
assert.equal(resultBody.execution.route_mode, 'job');
|
|
|
|
|
|
|
| 1108 |
assert.equal(typeof resultBody.timing.elapsed_ms, 'number');
|
| 1109 |
assert.equal(resultBody.timing.elapsed_ms >= 0, true);
|
| 1110 |
} finally {
|
|
@@ -1141,6 +1261,8 @@ describe('Agent route integration', () => {
|
|
| 1141 |
assert.equal(resultBody.execution.transport, 'agent_job_polling');
|
| 1142 |
assert.equal(resultBody.execution.endpoint, '/api/agent/image-requests');
|
| 1143 |
assert.equal(resultBody.execution.route_mode, 'job');
|
|
|
|
|
|
|
| 1144 |
assert.equal(upstreamCalls, 1);
|
| 1145 |
} finally {
|
| 1146 |
await upstream.close();
|
|
|
|
| 37 |
process.env.AGENT_STATE_BACKEND = 'sqlite';
|
| 38 |
process.env.AGENT_SQLITE_PATH = path.join(tempDir, 'agent.sqlite');
|
| 39 |
process.env.NEXT_PUBLIC_IMAGE_STORAGE_MODE = 'fs';
|
| 40 |
+
for (const key of Object.keys(process.env)) {
|
| 41 |
+
if (/^OPENAI_CHANNEL_\d+_/.test(key)) {
|
| 42 |
+
delete process.env[key];
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
delete process.env.APP_PASSWORD;
|
| 46 |
delete process.env.AGENT_API_TOKEN;
|
| 47 |
delete process.env.OPENAI_API_KEY;
|
|
|
|
| 52 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 53 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
| 54 |
delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE;
|
| 55 |
+
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES;
|
| 56 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID;
|
| 57 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET;
|
| 58 |
delete process.env.OPENAI_CHANNEL_1_USER_AGENT;
|
|
|
|
| 61 |
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
|
| 62 |
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
|
| 63 |
delete process.env.OPENAI_CHANNEL_2_UPSTREAM_PROFILE;
|
| 64 |
+
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODES;
|
| 65 |
delete process.env.OPENAI_CHANNEL_2_MATSCA_APP_ID;
|
| 66 |
delete process.env.OPENAI_CHANNEL_2_MATSCA_APP_SECRET;
|
| 67 |
delete process.env.OPENAI_CHANNEL_2_USER_AGENT;
|
|
|
|
| 142 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 143 |
{
|
| 144 |
id: 'matsca',
|
| 145 |
+
request_modes: [
|
| 146 |
+
'images-non-stream',
|
| 147 |
+
'images-sse',
|
| 148 |
+
'responses-non-stream',
|
| 149 |
+
'responses-sse'
|
| 150 |
+
],
|
| 151 |
request_headers: {
|
| 152 |
user_agent_effective: 'configured',
|
| 153 |
has_extra_headers: true,
|
|
|
|
| 216 |
assert.deepEqual(body.upstream_request_headers.channels, [
|
| 217 |
{
|
| 218 |
id: 'matsca',
|
| 219 |
+
request_modes: [
|
| 220 |
+
'images-non-stream',
|
| 221 |
+
'images-sse',
|
| 222 |
+
'responses-non-stream',
|
| 223 |
+
'responses-sse'
|
| 224 |
+
],
|
| 225 |
request_headers: {
|
| 226 |
user_agent_effective: 'configured',
|
| 227 |
has_extra_headers: true,
|
|
|
|
| 268 |
assert.equal(firstBody.execution.image_backend, 'images-api');
|
| 269 |
assert.equal(firstBody.execution.stream_mode, 'non_stream');
|
| 270 |
assert.equal(firstBody.execution.streaming_strategy, 'auto');
|
| 271 |
+
assert.equal(firstBody.execution.channel_request_mode, 'images-non-stream');
|
| 272 |
+
assert.equal(firstBody.execution.channel_request_mode_fallback_applied, false);
|
| 273 |
+
assert.deepEqual(firstBody.execution.route_decision, {
|
| 274 |
+
requested_backend: 'images-api',
|
| 275 |
+
preferred_channel_request_mode: 'images-non-stream',
|
| 276 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 277 |
+
fallback_applied: false,
|
| 278 |
+
selected_channel_id: 'default',
|
| 279 |
+
upstream_host: new URL(upstream.baseUrl).host
|
| 280 |
+
});
|
| 281 |
assert.equal(firstBody.execution.upstream_host, new URL(upstream.baseUrl).host);
|
| 282 |
assert.equal(firstBody.execution.request_headers.user_agent_effective, 'gpt-image-playground/2.1.0');
|
| 283 |
assert.equal(firstBody.execution.request_headers.has_extra_headers, false);
|
|
|
|
| 419 |
);
|
| 420 |
|
| 421 |
assert.equal(response.status, 200);
|
| 422 |
+
const body = await response.json();
|
| 423 |
+
assert.equal(body.execution.channel_request_mode, 'images-non-stream');
|
| 424 |
+
assert.equal(body.execution.channel_request_mode_fallback_applied, false);
|
| 425 |
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 426 |
assert.equal(upstreamJson.stream, false);
|
| 427 |
assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
|
|
|
|
| 462 |
}
|
| 463 |
});
|
| 464 |
|
| 465 |
+
it('uses a non-streaming channel request mode when Agent auto streaming has no SSE channel', async () => {
|
| 466 |
+
const { generateImage } = await loadAgentRoutes();
|
| 467 |
+
let upstreamBody = '';
|
| 468 |
+
const upstream = await startImageUpstream((body) => {
|
| 469 |
+
upstreamBody = body;
|
| 470 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 471 |
+
});
|
| 472 |
+
process.env.OPENAI_CHANNEL_1_ID = 'json-only';
|
| 473 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
|
| 474 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key';
|
| 475 |
+
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
|
| 476 |
+
|
| 477 |
+
try {
|
| 478 |
+
const response = await generateImage(
|
| 479 |
+
agentJsonRequest('agent-auto-json-channel-key', {
|
| 480 |
+
prompt: 'agent auto json channel',
|
| 481 |
+
stream_mode: 'auto'
|
| 482 |
+
})
|
| 483 |
+
);
|
| 484 |
+
|
| 485 |
+
assert.equal(response.status, 200);
|
| 486 |
+
const body = await response.json();
|
| 487 |
+
assert.equal(body.execution.channel_request_mode, 'images-non-stream');
|
| 488 |
+
assert.equal(body.execution.channel_request_mode_fallback_applied, true);
|
| 489 |
+
assert.deepEqual(body.execution.route_decision, {
|
| 490 |
+
requested_backend: 'images-api',
|
| 491 |
+
preferred_channel_request_mode: 'images-sse',
|
| 492 |
+
fallback_channel_request_mode: 'images-non-stream',
|
| 493 |
+
selected_channel_request_mode: 'images-non-stream',
|
| 494 |
+
fallback_applied: true,
|
| 495 |
+
selected_channel_id: 'json-only',
|
| 496 |
+
upstream_host: new URL(upstream.baseUrl).host
|
| 497 |
+
});
|
| 498 |
+
const upstreamJson = JSON.parse(upstreamBody) as Record<string, unknown>;
|
| 499 |
+
assert.equal(upstreamJson.stream, false);
|
| 500 |
+
assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
|
| 501 |
+
} finally {
|
| 502 |
+
await upstream.close();
|
| 503 |
+
}
|
| 504 |
+
});
|
| 505 |
+
|
| 506 |
+
it('fails explicit Agent stream requests instead of falling back to non-streaming request modes', async () => {
|
| 507 |
+
const { generateImage } = await loadAgentRoutes();
|
| 508 |
+
let upstreamCalls = 0;
|
| 509 |
+
const upstream = await startImageUpstream(() => {
|
| 510 |
+
upstreamCalls += 1;
|
| 511 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 512 |
+
});
|
| 513 |
+
process.env.OPENAI_CHANNEL_1_ID = 'json-only';
|
| 514 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
|
| 515 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key';
|
| 516 |
+
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
|
| 517 |
+
|
| 518 |
+
try {
|
| 519 |
+
const response = await generateImage(
|
| 520 |
+
agentJsonRequest('agent-explicit-stream-no-sse-key', {
|
| 521 |
+
prompt: 'agent explicit stream no sse',
|
| 522 |
+
stream_mode: 'stream',
|
| 523 |
+
streaming_strategy: 'openai-sse',
|
| 524 |
+
partial_images: 2
|
| 525 |
+
})
|
| 526 |
+
);
|
| 527 |
+
|
| 528 |
+
assert.equal(response.status, 503);
|
| 529 |
+
const body = await response.json();
|
| 530 |
+
assert.equal(body.error.code, 'configuration_error');
|
| 531 |
+
assert.equal(body.error.diagnostics.channel_request_mode, 'images-sse');
|
| 532 |
+
assert.equal(body.error.diagnostics.channel_request_mode_fallback_applied, false);
|
| 533 |
+
assert.deepEqual(body.error.diagnostics.route_decision, {
|
| 534 |
+
requested_backend: 'images-api',
|
| 535 |
+
preferred_channel_request_mode: 'images-sse',
|
| 536 |
+
selected_channel_request_mode: 'images-sse',
|
| 537 |
+
fallback_applied: false,
|
| 538 |
+
no_channel_reason: '当前没有支持 images-sse 的健康渠道凭证。请调整请求策略或 OPENAI_CHANNEL_N_REQUEST_MODES。'
|
| 539 |
+
});
|
| 540 |
+
assert.equal(upstreamCalls, 0);
|
| 541 |
+
} finally {
|
| 542 |
+
await upstream.close();
|
| 543 |
+
}
|
| 544 |
+
});
|
| 545 |
+
|
| 546 |
it('rejects OpenAI-compatible Agent generate profile violations before calling upstream', async () => {
|
| 547 |
const { generateImage } = await loadAgentRoutes();
|
| 548 |
let upstreamCalls = 0;
|
|
|
|
| 1140 |
assert.match(body.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
|
| 1141 |
assert.equal(body.error.diagnostics.channel_cooldown_scope, 'channel');
|
| 1142 |
assert.equal(body.error.diagnostics.cooldown_target.channel_id, 'default');
|
| 1143 |
+
assert.equal(body.error.diagnostics.cooldown_target.request_mode, 'images-non-stream');
|
| 1144 |
assert.equal(typeof body.error.diagnostics.retry_after_ms, 'number');
|
| 1145 |
assert.equal(typeof body.error.diagnostics.cooldown_until, 'string');
|
| 1146 |
assert.equal(typeof body.error.diagnostics.elapsed_ms, 'number');
|
|
|
|
| 1170 |
assert.equal(diagnosticsBody.diagnostics.error.diagnostics.selected_channel_id, 'default');
|
| 1171 |
assert.match(diagnosticsBody.diagnostics.error.diagnostics.upstream_host, /^127\.0\.0\.1:\d+$/);
|
| 1172 |
assert.equal(diagnosticsBody.diagnostics.error.diagnostics.cooldown_target.channel_id, 'default');
|
| 1173 |
+
assert.equal(
|
| 1174 |
+
diagnosticsBody.diagnostics.error.diagnostics.cooldown_target.request_mode,
|
| 1175 |
+
'images-non-stream'
|
| 1176 |
+
);
|
| 1177 |
assert.equal(typeof diagnosticsBody.diagnostics.error.diagnostics.retry_after_ms, 'number');
|
| 1178 |
assert.equal(JSON.stringify(diagnosticsBody).includes('test-key'), false);
|
| 1179 |
} finally {
|
|
|
|
| 1223 |
assert.equal(resultBody.execution.transport, 'agent_job_polling');
|
| 1224 |
assert.equal(resultBody.execution.endpoint, '/api/agent/jobs/images/generate');
|
| 1225 |
assert.equal(resultBody.execution.route_mode, 'job');
|
| 1226 |
+
assert.equal(resultBody.execution.channel_request_mode, 'images-non-stream');
|
| 1227 |
+
assert.equal(resultBody.execution.channel_request_mode_fallback_applied, false);
|
| 1228 |
assert.equal(typeof resultBody.timing.elapsed_ms, 'number');
|
| 1229 |
assert.equal(resultBody.timing.elapsed_ms >= 0, true);
|
| 1230 |
} finally {
|
|
|
|
| 1261 |
assert.equal(resultBody.execution.transport, 'agent_job_polling');
|
| 1262 |
assert.equal(resultBody.execution.endpoint, '/api/agent/image-requests');
|
| 1263 |
assert.equal(resultBody.execution.route_mode, 'job');
|
| 1264 |
+
assert.equal(resultBody.execution.channel_request_mode, 'images-non-stream');
|
| 1265 |
+
assert.equal(resultBody.execution.channel_request_mode_fallback_applied, false);
|
| 1266 |
assert.equal(upstreamCalls, 1);
|
| 1267 |
} finally {
|
| 1268 |
await upstream.close();
|
src/app/api/agent/capabilities/route.ts
CHANGED
|
@@ -44,7 +44,7 @@ function readPublicChannelEnv(env: NodeJS.ProcessEnv): Record<string, string | u
|
|
| 44 |
const publicEnv: Record<string, string | undefined> = {};
|
| 45 |
for (const key of Object.keys(env)) {
|
| 46 |
const match =
|
| 47 |
-
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|UPSTREAM_PROFILE|PROVIDER_MANIFEST|API_KEYS|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON)$/.exec(key);
|
| 48 |
if (!match) continue;
|
| 49 |
const [, , fieldName] = match;
|
| 50 |
publicEnv[key] = readPublicChannelEnvValue(key, fieldName, env[key]);
|
|
|
|
| 44 |
const publicEnv: Record<string, string | undefined> = {};
|
| 45 |
for (const key of Object.keys(env)) {
|
| 46 |
const match =
|
| 47 |
+
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|UPSTREAM_PROFILE|PROVIDER_MANIFEST|REQUEST_MODES|API_KEYS|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON)$/.exec(key);
|
| 48 |
if (!match) continue;
|
| 49 |
const [, , fieldName] = match;
|
| 50 |
publicEnv[key] = readPublicChannelEnvValue(key, fieldName, env[key]);
|
src/app/api/deploy-marker/route.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
-
const deployMarker = {"schema_version":1,"local_sha":"
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
|
|
|
| 1 |
import { NextResponse } from 'next/server';
|
| 2 |
|
| 3 |
+
const deployMarker = {"schema_version":1,"local_sha":"34544ccd35f4321f92ab73ef5ac36603ee9f7720","created_at":"2026-06-24T14:38:59.964Z","deploy_id":"dee10b1a-4b9c-47bb-a7f3-89875a64183f"} as const;
|
| 4 |
|
| 5 |
export const dynamic = 'force-dynamic';
|
| 6 |
|
src/app/api/images/route.test.ts
CHANGED
|
@@ -32,6 +32,11 @@ function restoreProcessEnv(snapshot: NodeJS.ProcessEnv) {
|
|
| 32 |
beforeEach(() => {
|
| 33 |
originalEnv = { ...process.env };
|
| 34 |
console.error = () => {};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
delete process.env.APP_PASSWORD;
|
| 36 |
delete process.env.OPENAI_API_KEY;
|
| 37 |
delete process.env.OPENAI_API_BASE_URL;
|
|
@@ -39,6 +44,7 @@ beforeEach(() => {
|
|
| 39 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 40 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
| 41 |
delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE;
|
|
|
|
| 42 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID;
|
| 43 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET;
|
| 44 |
delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED;
|
|
@@ -118,6 +124,69 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
|
|
| 118 |
}
|
| 119 |
});
|
| 120 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
it('keeps the stable SSE contract for SDK-parsed multi-image results without partial events', async () => {
|
| 122 |
const { POST } = await import('./route');
|
| 123 |
const upstream = await startStreamingImageUpstream(async () => [
|
|
@@ -743,13 +812,13 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
|
|
| 743 |
it('rejects the experimental Responses API backend when the feature flag is disabled', async () => {
|
| 744 |
const { POST } = await import('./route');
|
| 745 |
const response = await POST(
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
);
|
| 754 |
|
| 755 |
assert.equal(response.status, 400);
|
|
@@ -761,13 +830,13 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
|
|
| 761 |
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 762 |
const { POST } = await import('./route');
|
| 763 |
const response = await POST(
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
);
|
| 772 |
|
| 773 |
assert.equal(response.status, 400);
|
|
@@ -781,14 +850,14 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
|
|
| 781 |
const { POST } = await import('./route');
|
| 782 |
|
| 783 |
const multiImage = await POST(
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
);
|
| 793 |
assert.equal(multiImage.status, 400);
|
| 794 |
assert.match(String(((await multiImage.json()) as Record<string, unknown>).error), /单张生成/);
|
|
@@ -799,15 +868,15 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
|
|
| 799 |
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1';
|
| 800 |
const { POST } = await import('./route');
|
| 801 |
const edit = await POST(
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
);
|
| 812 |
assert.equal(edit.status, 400);
|
| 813 |
assert.match(String(((await edit.json()) as Record<string, unknown>).error), /单张编辑/);
|
|
@@ -1293,7 +1362,10 @@ describe('POST /api/images streaming', { concurrency: false }, () => {
|
|
| 1293 |
};
|
| 1294 |
assert.equal(upstreamJson.tools?.[0]?.type, 'image_generation');
|
| 1295 |
assert.equal(upstreamJson.tools?.[0]?.action, 'edit');
|
| 1296 |
-
assert.equal(
|
|
|
|
|
|
|
|
|
|
| 1297 |
} finally {
|
| 1298 |
await upstream.close();
|
| 1299 |
}
|
|
|
|
| 32 |
beforeEach(() => {
|
| 33 |
originalEnv = { ...process.env };
|
| 34 |
console.error = () => {};
|
| 35 |
+
for (const key of Object.keys(process.env)) {
|
| 36 |
+
if (/^OPENAI_CHANNEL_\d+_/.test(key)) {
|
| 37 |
+
delete process.env[key];
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
delete process.env.APP_PASSWORD;
|
| 41 |
delete process.env.OPENAI_API_KEY;
|
| 42 |
delete process.env.OPENAI_API_BASE_URL;
|
|
|
|
| 44 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 45 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
| 46 |
delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE;
|
| 47 |
+
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES;
|
| 48 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_ID;
|
| 49 |
delete process.env.OPENAI_CHANNEL_1_MATSCA_APP_SECRET;
|
| 50 |
delete process.env.OPENAI_CHANNEL_RECOVERY_PROBE_ENABLED;
|
|
|
|
| 124 |
}
|
| 125 |
});
|
| 126 |
|
| 127 |
+
it('uses a non-streaming channel request mode when page auto streaming has no SSE channel', async () => {
|
| 128 |
+
const { POST } = await import('./route');
|
| 129 |
+
const upstreamBodies: string[] = [];
|
| 130 |
+
const upstream = await startImagesJsonUpstream(async (body, _url, request) => {
|
| 131 |
+
if (request.method === 'POST') {
|
| 132 |
+
upstreamBodies.push(body);
|
| 133 |
+
}
|
| 134 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 135 |
+
});
|
| 136 |
+
process.env.OPENAI_CHANNEL_1_ID = 'json-only';
|
| 137 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
|
| 138 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key';
|
| 139 |
+
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
|
| 140 |
+
|
| 141 |
+
try {
|
| 142 |
+
const response = await POST(
|
| 143 |
+
imageFormRequest({
|
| 144 |
+
streamMode: 'auto'
|
| 145 |
+
})
|
| 146 |
+
);
|
| 147 |
+
|
| 148 |
+
assert.equal(response.status, 200);
|
| 149 |
+
assert.notEqual(response.headers.get('content-type'), 'text/event-stream');
|
| 150 |
+
assert.equal(upstreamBodies.length, 1);
|
| 151 |
+
const upstreamJson = JSON.parse(upstreamBodies[0] || '{}') as Record<string, unknown>;
|
| 152 |
+
assert.equal(upstreamJson.stream, false);
|
| 153 |
+
assert.equal(Object.hasOwn(upstreamJson, 'partial_images'), false);
|
| 154 |
+
} finally {
|
| 155 |
+
await upstream.close();
|
| 156 |
+
}
|
| 157 |
+
});
|
| 158 |
+
|
| 159 |
+
it('fails explicit page stream requests instead of falling back to non-streaming request modes', async () => {
|
| 160 |
+
const { POST } = await import('./route');
|
| 161 |
+
const upstreamBodies: string[] = [];
|
| 162 |
+
const upstream = await startImagesJsonUpstream(async (body, _url, request) => {
|
| 163 |
+
if (request.method === 'POST') {
|
| 164 |
+
upstreamBodies.push(body);
|
| 165 |
+
}
|
| 166 |
+
return { data: [{ b64_json: PNG_BASE64 }] };
|
| 167 |
+
});
|
| 168 |
+
process.env.OPENAI_CHANNEL_1_ID = 'json-only';
|
| 169 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = upstream.baseUrl;
|
| 170 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'test-key';
|
| 171 |
+
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream';
|
| 172 |
+
|
| 173 |
+
try {
|
| 174 |
+
const response = await POST(
|
| 175 |
+
imageFormRequest({
|
| 176 |
+
streamMode: 'stream',
|
| 177 |
+
imageStreamingStrategy: 'openai-sse'
|
| 178 |
+
})
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
assert.equal(response.status, 503);
|
| 182 |
+
const body = (await response.json()) as { error?: string };
|
| 183 |
+
assert.match(body.error || '', /images-sse/);
|
| 184 |
+
assert.equal(upstreamBodies.length, 0);
|
| 185 |
+
} finally {
|
| 186 |
+
await upstream.close();
|
| 187 |
+
}
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
it('keeps the stable SSE contract for SDK-parsed multi-image results without partial events', async () => {
|
| 191 |
const { POST } = await import('./route');
|
| 192 |
const upstream = await startStreamingImageUpstream(async () => [
|
|
|
|
| 812 |
it('rejects the experimental Responses API backend when the feature flag is disabled', async () => {
|
| 813 |
const { POST } = await import('./route');
|
| 814 |
const response = await POST(
|
| 815 |
+
imageFormRequest({
|
| 816 |
+
apiBaseUrl: 'http://127.0.0.1:1/v1',
|
| 817 |
+
apiKey: 'test-key',
|
| 818 |
+
stream: false,
|
| 819 |
+
streamMode: 'non_stream',
|
| 820 |
+
imageBackend: 'responses'
|
| 821 |
+
})
|
| 822 |
);
|
| 823 |
|
| 824 |
assert.equal(response.status, 400);
|
|
|
|
| 830 |
process.env.ENABLE_RESPONSES_IMAGE_BACKEND = 'true';
|
| 831 |
const { POST } = await import('./route');
|
| 832 |
const response = await POST(
|
| 833 |
+
imageFormRequest({
|
| 834 |
+
apiBaseUrl: 'http://127.0.0.1:1/v1',
|
| 835 |
+
apiKey: 'test-key',
|
| 836 |
+
stream: false,
|
| 837 |
+
streamMode: 'non_stream',
|
| 838 |
+
imageBackend: 'responses'
|
| 839 |
+
})
|
| 840 |
);
|
| 841 |
|
| 842 |
assert.equal(response.status, 400);
|
|
|
|
| 850 |
const { POST } = await import('./route');
|
| 851 |
|
| 852 |
const multiImage = await POST(
|
| 853 |
+
imageFormRequest({
|
| 854 |
+
apiBaseUrl: 'http://127.0.0.1:1/v1',
|
| 855 |
+
apiKey: 'test-key',
|
| 856 |
+
stream: false,
|
| 857 |
+
streamMode: 'non_stream',
|
| 858 |
+
imageBackend: 'responses',
|
| 859 |
+
n: '2'
|
| 860 |
+
})
|
| 861 |
);
|
| 862 |
assert.equal(multiImage.status, 400);
|
| 863 |
assert.match(String(((await multiImage.json()) as Record<string, unknown>).error), /单张生成/);
|
|
|
|
| 868 |
process.env.OPENAI_RESPONSES_API_MODEL = 'gpt-4.1';
|
| 869 |
const { POST } = await import('./route');
|
| 870 |
const edit = await POST(
|
| 871 |
+
imageFormRequest({
|
| 872 |
+
apiBaseUrl: 'http://127.0.0.1:1/v1',
|
| 873 |
+
apiKey: 'test-key',
|
| 874 |
+
stream: false,
|
| 875 |
+
streamMode: 'non_stream',
|
| 876 |
+
imageBackend: 'responses',
|
| 877 |
+
n: '2',
|
| 878 |
+
mode: 'edit'
|
| 879 |
+
})
|
| 880 |
);
|
| 881 |
assert.equal(edit.status, 400);
|
| 882 |
assert.match(String(((await edit.json()) as Record<string, unknown>).error), /单张编辑/);
|
|
|
|
| 1362 |
};
|
| 1363 |
assert.equal(upstreamJson.tools?.[0]?.type, 'image_generation');
|
| 1364 |
assert.equal(upstreamJson.tools?.[0]?.action, 'edit');
|
| 1365 |
+
assert.equal(
|
| 1366 |
+
upstreamJson.input?.[0]?.content?.some((item) => item.type === 'input_image'),
|
| 1367 |
+
true
|
| 1368 |
+
);
|
| 1369 |
} finally {
|
| 1370 |
await upstream.close();
|
| 1371 |
}
|
src/app/api/images/route.ts
CHANGED
|
@@ -43,6 +43,11 @@ import {
|
|
| 43 |
type ImageStreamMode,
|
| 44 |
type ImageStreamingStrategy
|
| 45 |
} from '@/lib/image-upstream-strategy';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth';
|
| 47 |
import { getServerChannelState } from '@/lib/server-channel-router';
|
| 48 |
import { createOpenAIImageClientOptions } from '@/lib/openai-image-transport';
|
|
@@ -60,6 +65,7 @@ type StreamResolutionInput = {
|
|
| 60 |
operation: StreamingOperation;
|
| 61 |
selectedCredential?: ChannelCredential;
|
| 62 |
sourceId?: string;
|
|
|
|
| 63 |
};
|
| 64 |
|
| 65 |
type StreamResolution = {
|
|
@@ -69,6 +75,17 @@ type StreamResolution = {
|
|
| 69 |
streamingMarkedUnavailable: boolean;
|
| 70 |
};
|
| 71 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
function readErrorStatus(error: unknown): number | undefined {
|
| 73 |
if (typeof error !== 'object' || error === null) return undefined;
|
| 74 |
if ('status' in error && typeof error.status === 'number') return error.status;
|
|
@@ -122,6 +139,14 @@ function normalizeAvailabilityBaseUrl(baseUrl: string | undefined): string {
|
|
| 122 |
function resolvePageStream(input: StreamResolutionInput): StreamResolution {
|
| 123 |
const availabilityKey = createAvailabilityKey(input);
|
| 124 |
const streamingAvailability = getServerChannelState().streamingAvailability;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
if (input.streamMode === 'non_stream') {
|
| 126 |
return {
|
| 127 |
availabilityKey,
|
|
@@ -161,6 +186,67 @@ function resolvePageStream(input: StreamResolutionInput): StreamResolution {
|
|
| 161 |
};
|
| 162 |
}
|
| 163 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
function markStreamingUnavailable(input: {
|
| 165 |
key: StreamingAvailabilityKey;
|
| 166 |
error?: unknown;
|
|
@@ -228,6 +314,7 @@ function releaseChannelLeaseAfterResponse(response: Response, lease: ChannelCapa
|
|
| 228 |
|
| 229 |
export async function POST(request: NextRequest) {
|
| 230 |
let selectedServerCredential: ChannelCredential | undefined;
|
|
|
|
| 231 |
let channelLease: ChannelCapacityLease | undefined;
|
| 232 |
let clientRequestId: string | undefined;
|
| 233 |
let requestLogContext: RequestLogContext | undefined;
|
|
@@ -252,9 +339,24 @@ export async function POST(request: NextRequest) {
|
|
| 252 |
);
|
| 253 |
assertSafeApiOverride(requestApiKey, requestApiBaseUrl);
|
| 254 |
validateApiBaseUrl(requestApiBaseUrl, { allowedPlainHttpBaseUrls });
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
const {
|
| 259 |
apiKey: effectiveApiKey,
|
| 260 |
baseUrl: effectiveApiBaseUrl,
|
|
@@ -280,7 +382,11 @@ export async function POST(request: NextRequest) {
|
|
| 280 |
if (selectedCredential) {
|
| 281 |
appLogger.info(
|
| 282 |
`已选择 API 渠道:${selectedCredential.channelId},凭证:${selectedCredential.id},策略:server`,
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
);
|
| 285 |
}
|
| 286 |
|
|
@@ -337,12 +443,9 @@ export async function POST(request: NextRequest) {
|
|
| 337 |
requestLogContext
|
| 338 |
);
|
| 339 |
|
| 340 |
-
const streamMode = readImageStreamMode(formData, process.env);
|
| 341 |
const partialImagesCount = toPartialImagesCount(
|
| 342 |
readCount(formData, 'partial_images', 2, upstreamProfile.partialImages.min, upstreamProfile.partialImages.max)
|
| 343 |
);
|
| 344 |
-
const imageBackend = readImageGenerationBackend(formData, process.env);
|
| 345 |
-
const streamingStrategy = readImageStreamingStrategy(formData, process.env);
|
| 346 |
const streamResolution = resolvePageStream({
|
| 347 |
streamMode,
|
| 348 |
imageBackend,
|
|
@@ -352,7 +455,8 @@ export async function POST(request: NextRequest) {
|
|
| 352 |
sourceId: createAvailabilitySourceId({
|
| 353 |
selectedCredential,
|
| 354 |
baseUrl: effectiveApiBaseUrl
|
| 355 |
-
})
|
|
|
|
| 356 |
});
|
| 357 |
assertResponsesImageBackendAllowed({ imageBackend, mode });
|
| 358 |
appLogger.info('图片上游兼容策略。', {
|
|
@@ -363,6 +467,8 @@ export async function POST(request: NextRequest) {
|
|
| 363 |
streamEnabled: streamResolution.streamEnabled,
|
| 364 |
streamFallbackEnabled: streamResolution.streamFallbackEnabled,
|
| 365 |
streamingMarkedUnavailable: streamResolution.streamingMarkedUnavailable,
|
|
|
|
|
|
|
| 366 |
upstreamProfile: upstreamProfile.id,
|
| 367 |
upstreamExtraHeaders: Boolean(upstreamHeaders)
|
| 368 |
});
|
|
@@ -503,7 +609,7 @@ export async function POST(request: NextRequest) {
|
|
| 503 |
preview: typeof invalidResult === 'string' ? invalidResult.slice(0, 300) : invalidResult,
|
| 504 |
...requestLogContext
|
| 505 |
});
|
| 506 |
-
reportServerCredentialFailure(selectedCredential, { status: 502 });
|
| 507 |
const response = NextResponse.json({ error: describeInvalidImagesResponse(invalidResult) }, { status: 502 });
|
| 508 |
const responseWithHeaders = appendChannelQueueHeaders(response, channelLease);
|
| 509 |
channelLease?.release();
|
|
@@ -513,7 +619,7 @@ export async function POST(request: NextRequest) {
|
|
| 513 |
} catch (error: unknown) {
|
| 514 |
channelLease?.release();
|
| 515 |
channelLease = undefined;
|
| 516 |
-
reportServerCredentialFailure(selectedServerCredential, error);
|
| 517 |
appLogger.error('/api/images 处理失败:', {
|
| 518 |
...requestLogContext,
|
| 519 |
error: error instanceof Error ? error.message : String(error)
|
|
|
|
| 43 |
type ImageStreamMode,
|
| 44 |
type ImageStreamingStrategy
|
| 45 |
} from '@/lib/image-upstream-strategy';
|
| 46 |
+
import {
|
| 47 |
+
isStreamingChannelRequestMode,
|
| 48 |
+
resolveChannelRequestMode,
|
| 49 |
+
type ChannelRequestMode
|
| 50 |
+
} from '@/lib/channel-request-mode';
|
| 51 |
import { PAGE_PASSWORD_AUTH_ERROR_CODES } from '@/lib/page-password-auth';
|
| 52 |
import { getServerChannelState } from '@/lib/server-channel-router';
|
| 53 |
import { createOpenAIImageClientOptions } from '@/lib/openai-image-transport';
|
|
|
|
| 65 |
operation: StreamingOperation;
|
| 66 |
selectedCredential?: ChannelCredential;
|
| 67 |
sourceId?: string;
|
| 68 |
+
forceNonStream?: boolean;
|
| 69 |
};
|
| 70 |
|
| 71 |
type StreamResolution = {
|
|
|
|
| 75 |
streamingMarkedUnavailable: boolean;
|
| 76 |
};
|
| 77 |
|
| 78 |
+
type ChannelRequestModePlan = {
|
| 79 |
+
preferred: ChannelRequestMode;
|
| 80 |
+
fallback?: ChannelRequestMode;
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
type PageChannelSelection = {
|
| 84 |
+
selectedCredential?: ChannelCredential;
|
| 85 |
+
requestMode: ChannelRequestMode;
|
| 86 |
+
forcedNonStream: boolean;
|
| 87 |
+
};
|
| 88 |
+
|
| 89 |
function readErrorStatus(error: unknown): number | undefined {
|
| 90 |
if (typeof error !== 'object' || error === null) return undefined;
|
| 91 |
if ('status' in error && typeof error.status === 'number') return error.status;
|
|
|
|
| 139 |
function resolvePageStream(input: StreamResolutionInput): StreamResolution {
|
| 140 |
const availabilityKey = createAvailabilityKey(input);
|
| 141 |
const streamingAvailability = getServerChannelState().streamingAvailability;
|
| 142 |
+
if (input.forceNonStream) {
|
| 143 |
+
return {
|
| 144 |
+
availabilityKey,
|
| 145 |
+
streamEnabled: false,
|
| 146 |
+
streamFallbackEnabled: false,
|
| 147 |
+
streamingMarkedUnavailable: streamingAvailability.isUnavailable(availabilityKey)
|
| 148 |
+
};
|
| 149 |
+
}
|
| 150 |
if (input.streamMode === 'non_stream') {
|
| 151 |
return {
|
| 152 |
availabilityKey,
|
|
|
|
| 186 |
};
|
| 187 |
}
|
| 188 |
|
| 189 |
+
function resolvePageChannelRequestModePlan(input: {
|
| 190 |
+
streamMode: ImageStreamMode;
|
| 191 |
+
imageBackend: ImageGenerationBackend;
|
| 192 |
+
streamingStrategy: ImageStreamingStrategy;
|
| 193 |
+
}): ChannelRequestModePlan {
|
| 194 |
+
if (input.streamMode === 'non_stream') {
|
| 195 |
+
return {
|
| 196 |
+
preferred: resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false })
|
| 197 |
+
};
|
| 198 |
+
}
|
| 199 |
+
if (input.streamMode === 'auto' && input.streamingStrategy === 'off') {
|
| 200 |
+
return {
|
| 201 |
+
preferred: resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false })
|
| 202 |
+
};
|
| 203 |
+
}
|
| 204 |
+
const preferred = resolveChannelRequestMode({
|
| 205 |
+
imageBackend: input.imageBackend,
|
| 206 |
+
streamEnabled: resolveImageStreamEnabled({
|
| 207 |
+
imageBackend: input.imageBackend,
|
| 208 |
+
requestedStream: true,
|
| 209 |
+
streamingStrategy: input.streamingStrategy
|
| 210 |
+
})
|
| 211 |
+
});
|
| 212 |
+
if (input.streamMode !== 'auto' || !isStreamingChannelRequestMode(preferred)) {
|
| 213 |
+
return { preferred };
|
| 214 |
+
}
|
| 215 |
+
return {
|
| 216 |
+
preferred,
|
| 217 |
+
fallback: resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false })
|
| 218 |
+
};
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
function selectPageServerCredential(input: {
|
| 222 |
+
router: NonNullable<ReturnType<typeof getServerChannelState>['router']>;
|
| 223 |
+
affinityKey: string;
|
| 224 |
+
plan: ChannelRequestModePlan;
|
| 225 |
+
}): PageChannelSelection {
|
| 226 |
+
try {
|
| 227 |
+
return {
|
| 228 |
+
selectedCredential: input.router.select({
|
| 229 |
+
affinityKey: input.affinityKey,
|
| 230 |
+
requestMode: input.plan.preferred
|
| 231 |
+
}),
|
| 232 |
+
requestMode: input.plan.preferred,
|
| 233 |
+
forcedNonStream: false
|
| 234 |
+
};
|
| 235 |
+
} catch (error) {
|
| 236 |
+
if (!input.plan.fallback || !(error instanceof RequestValidationError)) {
|
| 237 |
+
throw error;
|
| 238 |
+
}
|
| 239 |
+
return {
|
| 240 |
+
selectedCredential: input.router.select({
|
| 241 |
+
affinityKey: input.affinityKey,
|
| 242 |
+
requestMode: input.plan.fallback
|
| 243 |
+
}),
|
| 244 |
+
requestMode: input.plan.fallback,
|
| 245 |
+
forcedNonStream: true
|
| 246 |
+
};
|
| 247 |
+
}
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
function markStreamingUnavailable(input: {
|
| 251 |
key: StreamingAvailabilityKey;
|
| 252 |
error?: unknown;
|
|
|
|
| 314 |
|
| 315 |
export async function POST(request: NextRequest) {
|
| 316 |
let selectedServerCredential: ChannelCredential | undefined;
|
| 317 |
+
let selectedServerRequestMode: ChannelRequestMode | undefined;
|
| 318 |
let channelLease: ChannelCapacityLease | undefined;
|
| 319 |
let clientRequestId: string | undefined;
|
| 320 |
let requestLogContext: RequestLogContext | undefined;
|
|
|
|
| 339 |
);
|
| 340 |
assertSafeApiOverride(requestApiKey, requestApiBaseUrl);
|
| 341 |
validateApiBaseUrl(requestApiBaseUrl, { allowedPlainHttpBaseUrls });
|
| 342 |
+
const streamMode = readImageStreamMode(formData, process.env);
|
| 343 |
+
const imageBackend = readImageGenerationBackend(formData, process.env);
|
| 344 |
+
const streamingStrategy = readImageStreamingStrategy(formData, process.env);
|
| 345 |
+
const requestModePlan = resolvePageChannelRequestModePlan({ streamMode, imageBackend, streamingStrategy });
|
| 346 |
+
const channelSelection =
|
| 347 |
+
requestApiKey || !serverChannelRouter
|
| 348 |
+
? {
|
| 349 |
+
selectedCredential: undefined,
|
| 350 |
+
requestMode: requestModePlan.preferred,
|
| 351 |
+
forcedNonStream: false
|
| 352 |
+
}
|
| 353 |
+
: selectPageServerCredential({
|
| 354 |
+
router: serverChannelRouter,
|
| 355 |
+
affinityKey: readAffinityKey(request.headers),
|
| 356 |
+
plan: requestModePlan
|
| 357 |
+
});
|
| 358 |
+
selectedServerCredential = channelSelection.selectedCredential;
|
| 359 |
+
selectedServerRequestMode = channelSelection.requestMode;
|
| 360 |
const {
|
| 361 |
apiKey: effectiveApiKey,
|
| 362 |
baseUrl: effectiveApiBaseUrl,
|
|
|
|
| 382 |
if (selectedCredential) {
|
| 383 |
appLogger.info(
|
| 384 |
`已选择 API 渠道:${selectedCredential.channelId},凭证:${selectedCredential.id},策略:server`,
|
| 385 |
+
{
|
| 386 |
+
...requestLogContext,
|
| 387 |
+
channelRequestMode: channelSelection.requestMode,
|
| 388 |
+
channelRequestModeFallbackApplied: channelSelection.forcedNonStream
|
| 389 |
+
}
|
| 390 |
);
|
| 391 |
}
|
| 392 |
|
|
|
|
| 443 |
requestLogContext
|
| 444 |
);
|
| 445 |
|
|
|
|
| 446 |
const partialImagesCount = toPartialImagesCount(
|
| 447 |
readCount(formData, 'partial_images', 2, upstreamProfile.partialImages.min, upstreamProfile.partialImages.max)
|
| 448 |
);
|
|
|
|
|
|
|
| 449 |
const streamResolution = resolvePageStream({
|
| 450 |
streamMode,
|
| 451 |
imageBackend,
|
|
|
|
| 455 |
sourceId: createAvailabilitySourceId({
|
| 456 |
selectedCredential,
|
| 457 |
baseUrl: effectiveApiBaseUrl
|
| 458 |
+
}),
|
| 459 |
+
forceNonStream: channelSelection.forcedNonStream
|
| 460 |
});
|
| 461 |
assertResponsesImageBackendAllowed({ imageBackend, mode });
|
| 462 |
appLogger.info('图片上游兼容策略。', {
|
|
|
|
| 467 |
streamEnabled: streamResolution.streamEnabled,
|
| 468 |
streamFallbackEnabled: streamResolution.streamFallbackEnabled,
|
| 469 |
streamingMarkedUnavailable: streamResolution.streamingMarkedUnavailable,
|
| 470 |
+
channelRequestMode: channelSelection.requestMode,
|
| 471 |
+
channelRequestModeFallbackApplied: channelSelection.forcedNonStream,
|
| 472 |
upstreamProfile: upstreamProfile.id,
|
| 473 |
upstreamExtraHeaders: Boolean(upstreamHeaders)
|
| 474 |
});
|
|
|
|
| 609 |
preview: typeof invalidResult === 'string' ? invalidResult.slice(0, 300) : invalidResult,
|
| 610 |
...requestLogContext
|
| 611 |
});
|
| 612 |
+
reportServerCredentialFailure(selectedCredential, { status: 502 }, selectedServerRequestMode);
|
| 613 |
const response = NextResponse.json({ error: describeInvalidImagesResponse(invalidResult) }, { status: 502 });
|
| 614 |
const responseWithHeaders = appendChannelQueueHeaders(response, channelLease);
|
| 615 |
channelLease?.release();
|
|
|
|
| 619 |
} catch (error: unknown) {
|
| 620 |
channelLease?.release();
|
| 621 |
channelLease = undefined;
|
| 622 |
+
reportServerCredentialFailure(selectedServerCredential, error, selectedServerRequestMode);
|
| 623 |
appLogger.error('/api/images 处理失败:', {
|
| 624 |
...requestLogContext,
|
| 625 |
error: error instanceof Error ? error.message : String(error)
|
src/app/api/runtime-capabilities/route.test.ts
CHANGED
|
@@ -26,16 +26,19 @@ beforeEach(() => {
|
|
| 26 |
delete process.env.IMAGE_UPSTREAM_MAX_RETRIES;
|
| 27 |
delete process.env.OPENAI_API_KEY;
|
| 28 |
delete process.env.OPENAI_API_BASE_URL;
|
|
|
|
| 29 |
delete process.env.OPENAI_CHANNEL_1_ID;
|
| 30 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 31 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
| 32 |
delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE;
|
| 33 |
delete process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST;
|
|
|
|
| 34 |
delete process.env.OPENAI_CHANNEL_2_ID;
|
| 35 |
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
|
| 36 |
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
|
| 37 |
delete process.env.OPENAI_CHANNEL_2_UPSTREAM_PROFILE;
|
| 38 |
delete process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST;
|
|
|
|
| 39 |
delete process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED;
|
| 40 |
delete process.env.OPENAI_CHANNEL_QUEUE_ENABLED;
|
| 41 |
delete process.env.OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS;
|
|
@@ -59,7 +62,10 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 59 |
it('exposes streaming batch capability by default without the removed env gate', async () => {
|
| 60 |
const { GET } = await import('./route');
|
| 61 |
|
| 62 |
-
const body = (await (await GET()).json()) as Record<
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
assert.equal(body.streamingBatch.enabled, true);
|
| 65 |
assert.equal(typeof body.streamingBatch.recommendedConcurrency, 'number');
|
|
@@ -204,7 +210,10 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 204 |
assert.equal(defaultBody.upstreamProfile.serverProfile, 'openai-compatible');
|
| 205 |
assert.equal(defaultBody.upstreamProfile.serverProfileMixed, false);
|
| 206 |
assert.equal(defaultBody.upstreamProfile.requestProfile, 'openai-compatible');
|
| 207 |
-
assert.equal(
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
process.env.OPENAI_CHANNEL_1_ID = 'matsca';
|
| 210 |
process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-matsca';
|
|
@@ -223,7 +232,10 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 223 |
(matscaBody.upstreamProfile.activeConstraints as { generateCount: { max: number } }).generateCount.max,
|
| 224 |
4
|
| 225 |
);
|
| 226 |
-
assert.equal(
|
|
|
|
|
|
|
|
|
|
| 227 |
assert.equal(JSON.stringify(matscaBody).includes('sk-matsca'), false);
|
| 228 |
|
| 229 |
process.env.OPENAI_CHANNEL_2_ID = 'official';
|
|
@@ -240,7 +252,10 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 240 |
(mixedBody.upstreamProfile.activeConstraints as { generateCount: { max: number } }).generateCount.max,
|
| 241 |
4
|
| 242 |
);
|
| 243 |
-
assert.equal(
|
|
|
|
|
|
|
|
|
|
| 244 |
assert.deepEqual(
|
| 245 |
(mixedBody.upstreamProfile.activeConstraints as { partialImages: { min: number; max: number } })
|
| 246 |
.partialImages,
|
|
@@ -371,4 +386,119 @@ describe('GET /api/runtime-capabilities', { concurrency: false }, () => {
|
|
| 371 |
assert.equal(enabled.responsesImageBackend.hasDefaultModel, true);
|
| 372 |
assert.deepEqual(enabled.responsesImageBackend.missingEnv, []);
|
| 373 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
});
|
|
|
|
| 26 |
delete process.env.IMAGE_UPSTREAM_MAX_RETRIES;
|
| 27 |
delete process.env.OPENAI_API_KEY;
|
| 28 |
delete process.env.OPENAI_API_BASE_URL;
|
| 29 |
+
delete process.env.OPENAI_ROUTING_STRATEGY;
|
| 30 |
delete process.env.OPENAI_CHANNEL_1_ID;
|
| 31 |
delete process.env.OPENAI_CHANNEL_1_API_KEYS;
|
| 32 |
delete process.env.OPENAI_CHANNEL_1_BASE_URL;
|
| 33 |
delete process.env.OPENAI_CHANNEL_1_UPSTREAM_PROFILE;
|
| 34 |
delete process.env.OPENAI_CHANNEL_1_PROVIDER_MANIFEST;
|
| 35 |
+
delete process.env.OPENAI_CHANNEL_1_REQUEST_MODES;
|
| 36 |
delete process.env.OPENAI_CHANNEL_2_ID;
|
| 37 |
delete process.env.OPENAI_CHANNEL_2_API_KEYS;
|
| 38 |
delete process.env.OPENAI_CHANNEL_2_BASE_URL;
|
| 39 |
delete process.env.OPENAI_CHANNEL_2_UPSTREAM_PROFILE;
|
| 40 |
delete process.env.OPENAI_CHANNEL_2_PROVIDER_MANIFEST;
|
| 41 |
+
delete process.env.OPENAI_CHANNEL_2_REQUEST_MODES;
|
| 42 |
delete process.env.OPENAI_CHANNEL_FAILURE_COOLDOWN_ENABLED;
|
| 43 |
delete process.env.OPENAI_CHANNEL_QUEUE_ENABLED;
|
| 44 |
delete process.env.OPENAI_CHANNEL_QUEUE_MAX_WAIT_MS;
|
|
|
|
| 62 |
it('exposes streaming batch capability by default without the removed env gate', async () => {
|
| 63 |
const { GET } = await import('./route');
|
| 64 |
|
| 65 |
+
const body = (await (await GET()).json()) as Record<
|
| 66 |
+
string,
|
| 67 |
+
{ enabled: boolean; recommendedConcurrency?: number }
|
| 68 |
+
>;
|
| 69 |
|
| 70 |
assert.equal(body.streamingBatch.enabled, true);
|
| 71 |
assert.equal(typeof body.streamingBatch.recommendedConcurrency, 'number');
|
|
|
|
| 210 |
assert.equal(defaultBody.upstreamProfile.serverProfile, 'openai-compatible');
|
| 211 |
assert.equal(defaultBody.upstreamProfile.serverProfileMixed, false);
|
| 212 |
assert.equal(defaultBody.upstreamProfile.requestProfile, 'openai-compatible');
|
| 213 |
+
assert.equal(
|
| 214 |
+
(defaultBody.upstreamProfile.activeConstraints as { upload: { maxImages: number } }).upload.maxImages,
|
| 215 |
+
10
|
| 216 |
+
);
|
| 217 |
|
| 218 |
process.env.OPENAI_CHANNEL_1_ID = 'matsca';
|
| 219 |
process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-matsca';
|
|
|
|
| 232 |
(matscaBody.upstreamProfile.activeConstraints as { generateCount: { max: number } }).generateCount.max,
|
| 233 |
4
|
| 234 |
);
|
| 235 |
+
assert.equal(
|
| 236 |
+
(matscaBody.upstreamProfile.activeConstraints as { upload: { maxImages: number } }).upload.maxImages,
|
| 237 |
+
8
|
| 238 |
+
);
|
| 239 |
assert.equal(JSON.stringify(matscaBody).includes('sk-matsca'), false);
|
| 240 |
|
| 241 |
process.env.OPENAI_CHANNEL_2_ID = 'official';
|
|
|
|
| 252 |
(mixedBody.upstreamProfile.activeConstraints as { generateCount: { max: number } }).generateCount.max,
|
| 253 |
4
|
| 254 |
);
|
| 255 |
+
assert.equal(
|
| 256 |
+
(mixedBody.upstreamProfile.activeConstraints as { upload: { maxImages: number } }).upload.maxImages,
|
| 257 |
+
8
|
| 258 |
+
);
|
| 259 |
assert.deepEqual(
|
| 260 |
(mixedBody.upstreamProfile.activeConstraints as { partialImages: { min: number; max: number } })
|
| 261 |
.partialImages,
|
|
|
|
| 386 |
assert.equal(enabled.responsesImageBackend.hasDefaultModel, true);
|
| 387 |
assert.deepEqual(enabled.responsesImageBackend.missingEnv, []);
|
| 388 |
});
|
| 389 |
+
|
| 390 |
+
it('exposes sanitized channel request modes for routing diagnostics', async () => {
|
| 391 |
+
process.env.OPENAI_ROUTING_STRATEGY = 'round_robin';
|
| 392 |
+
process.env.OPENAI_CHANNEL_1_ID = 'images';
|
| 393 |
+
process.env.OPENAI_CHANNEL_1_BASE_URL = 'https://images.example.com/v1';
|
| 394 |
+
process.env.OPENAI_CHANNEL_1_API_KEYS = 'sk-secret';
|
| 395 |
+
process.env.OPENAI_CHANNEL_1_REQUEST_MODES = 'images-non-stream,images-sse';
|
| 396 |
+
const { GET } = await import('./route');
|
| 397 |
+
|
| 398 |
+
const body = (await (await GET()).json()) as {
|
| 399 |
+
channelRouting: {
|
| 400 |
+
strategy: string;
|
| 401 |
+
credentialCount: number;
|
| 402 |
+
channelCount: number;
|
| 403 |
+
supportedRequestModes: string[];
|
| 404 |
+
configuredRequestModes: string[];
|
| 405 |
+
effectiveRequestModes: string[];
|
| 406 |
+
requestModeControls: {
|
| 407 |
+
globalEnv: string;
|
| 408 |
+
channelEnvPattern: string;
|
| 409 |
+
mutableAtRuntime: boolean;
|
| 410 |
+
smokeGateCommands: Record<string, string[]>;
|
| 411 |
+
};
|
| 412 |
+
requestModeHealth: Array<{
|
| 413 |
+
mode: string;
|
| 414 |
+
configuredCredentialCount: number;
|
| 415 |
+
healthyCredentialCount: number;
|
| 416 |
+
configuredChannelCount: number;
|
| 417 |
+
healthyChannelCount: number;
|
| 418 |
+
}>;
|
| 419 |
+
requestModesByChannel: Array<{
|
| 420 |
+
channelId: string;
|
| 421 |
+
requestModes: string[];
|
| 422 |
+
}>;
|
| 423 |
+
effectiveRequestModesByChannel: Array<{
|
| 424 |
+
channelId: string;
|
| 425 |
+
requestModes: string[];
|
| 426 |
+
}>;
|
| 427 |
+
};
|
| 428 |
+
};
|
| 429 |
+
|
| 430 |
+
assert.deepEqual(body.channelRouting, {
|
| 431 |
+
strategy: 'round_robin',
|
| 432 |
+
credentialCount: 1,
|
| 433 |
+
channelCount: 1,
|
| 434 |
+
supportedRequestModes: ['images-non-stream', 'images-sse', 'responses-non-stream', 'responses-sse'],
|
| 435 |
+
configuredRequestModes: ['images-non-stream', 'images-sse'],
|
| 436 |
+
effectiveRequestModes: ['images-non-stream', 'images-sse'],
|
| 437 |
+
requestModeControls: {
|
| 438 |
+
source: 'admin_env_whitelist',
|
| 439 |
+
globalEnv: 'OPENAI_UPSTREAM_REQUEST_MODES',
|
| 440 |
+
channelEnvPattern: 'OPENAI_CHANNEL_N_REQUEST_MODES',
|
| 441 |
+
mutableAtRuntime: false,
|
| 442 |
+
finalGateCommand:
|
| 443 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable',
|
| 444 |
+
smokeGateCommands: {
|
| 445 |
+
'images-non-stream': [
|
| 446 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case original-images-json --allow-billable'
|
| 447 |
+
],
|
| 448 |
+
'images-sse': [
|
| 449 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-images-sse --allow-billable'
|
| 450 |
+
],
|
| 451 |
+
'responses-non-stream': [
|
| 452 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-responses-json --allow-billable'
|
| 453 |
+
],
|
| 454 |
+
'responses-sse': [
|
| 455 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
|
| 456 |
+
]
|
| 457 |
+
}
|
| 458 |
+
},
|
| 459 |
+
requestModeHealth: [
|
| 460 |
+
{
|
| 461 |
+
mode: 'images-non-stream',
|
| 462 |
+
configuredCredentialCount: 1,
|
| 463 |
+
healthyCredentialCount: 1,
|
| 464 |
+
configuredChannelCount: 1,
|
| 465 |
+
healthyChannelCount: 1
|
| 466 |
+
},
|
| 467 |
+
{
|
| 468 |
+
mode: 'images-sse',
|
| 469 |
+
configuredCredentialCount: 1,
|
| 470 |
+
healthyCredentialCount: 1,
|
| 471 |
+
configuredChannelCount: 1,
|
| 472 |
+
healthyChannelCount: 1
|
| 473 |
+
},
|
| 474 |
+
{
|
| 475 |
+
mode: 'responses-non-stream',
|
| 476 |
+
configuredCredentialCount: 0,
|
| 477 |
+
healthyCredentialCount: 0,
|
| 478 |
+
configuredChannelCount: 0,
|
| 479 |
+
healthyChannelCount: 0
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
mode: 'responses-sse',
|
| 483 |
+
configuredCredentialCount: 0,
|
| 484 |
+
healthyCredentialCount: 0,
|
| 485 |
+
configuredChannelCount: 0,
|
| 486 |
+
healthyChannelCount: 0
|
| 487 |
+
}
|
| 488 |
+
],
|
| 489 |
+
requestModesByChannel: [
|
| 490 |
+
{
|
| 491 |
+
channelId: 'images',
|
| 492 |
+
requestModes: ['images-non-stream', 'images-sse']
|
| 493 |
+
}
|
| 494 |
+
],
|
| 495 |
+
effectiveRequestModesByChannel: [
|
| 496 |
+
{
|
| 497 |
+
channelId: 'images',
|
| 498 |
+
requestModes: ['images-non-stream', 'images-sse']
|
| 499 |
+
}
|
| 500 |
+
]
|
| 501 |
+
});
|
| 502 |
+
assert.equal(JSON.stringify(body.channelRouting).includes('sk-secret'), false);
|
| 503 |
+
});
|
| 504 |
});
|
src/app/api/runtime-capabilities/route.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import { getChannelPoolSummary, toPublicChannelFailure } from '@/lib/channel-router';
|
|
|
|
| 2 |
import { summarizeImageUpstreamProfile } from '@/lib/image-upstream-profile';
|
| 3 |
import { readImageStreamMode, readImageStreamingStrategy } from '@/lib/image-upstream-strategy';
|
| 4 |
import { summarizeOpenAIImageTransport } from '@/lib/openai-image-transport';
|
|
@@ -15,6 +16,7 @@ export async function GET() {
|
|
| 15 |
const serverChannelState = getServerChannelState();
|
| 16 |
const summary = getChannelPoolSummary(serverChannelState.config);
|
| 17 |
const healthSummary = serverChannelState.router?.getHealthSummary();
|
|
|
|
| 18 |
const maxStreamsPerCredential = readPositiveIntegerEnv(process.env, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1);
|
| 19 |
const channelQueueSummary = serverChannelState.channelCapacityQueue.summary();
|
| 20 |
const responsesImageBackendEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND');
|
|
@@ -74,6 +76,28 @@ export async function GET() {
|
|
| 74 |
pendingProbeChannelCount: healthSummary?.pendingRecoveryProbeChannelCount ?? 0,
|
| 75 |
probe: serverChannelState.channelRecoveryProber?.summary()
|
| 76 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
upstreamProfile,
|
| 78 |
imageTransport: summarizeOpenAIImageTransport(process.env),
|
| 79 |
providerManifests,
|
|
|
|
| 1 |
import { getChannelPoolSummary, toPublicChannelFailure } from '@/lib/channel-router';
|
| 2 |
+
import { CHANNEL_REQUEST_MODES, CHANNEL_REQUEST_MODE_ADMIN_CONTROL } from '@/lib/channel-request-mode';
|
| 3 |
import { summarizeImageUpstreamProfile } from '@/lib/image-upstream-profile';
|
| 4 |
import { readImageStreamMode, readImageStreamingStrategy } from '@/lib/image-upstream-strategy';
|
| 5 |
import { summarizeOpenAIImageTransport } from '@/lib/openai-image-transport';
|
|
|
|
| 16 |
const serverChannelState = getServerChannelState();
|
| 17 |
const summary = getChannelPoolSummary(serverChannelState.config);
|
| 18 |
const healthSummary = serverChannelState.router?.getHealthSummary();
|
| 19 |
+
const requestModeHealthSummary = serverChannelState.router?.getRequestModeHealthSummary();
|
| 20 |
const maxStreamsPerCredential = readPositiveIntegerEnv(process.env, 'OPENAI_MAX_STREAMS_PER_CREDENTIAL', 1);
|
| 21 |
const channelQueueSummary = serverChannelState.channelCapacityQueue.summary();
|
| 22 |
const responsesImageBackendEnabled = readBooleanEnv(process.env, 'ENABLE_RESPONSES_IMAGE_BACKEND');
|
|
|
|
| 76 |
pendingProbeChannelCount: healthSummary?.pendingRecoveryProbeChannelCount ?? 0,
|
| 77 |
probe: serverChannelState.channelRecoveryProber?.summary()
|
| 78 |
},
|
| 79 |
+
channelRouting: {
|
| 80 |
+
strategy: summary.strategy,
|
| 81 |
+
credentialCount: summary.credentialCount,
|
| 82 |
+
channelCount: summary.channelCount,
|
| 83 |
+
supportedRequestModes: CHANNEL_REQUEST_MODES,
|
| 84 |
+
configuredRequestModes:
|
| 85 |
+
requestModeHealthSummary?.configuredRequestModes ?? (summary.credentialCount > 0 ? CHANNEL_REQUEST_MODES : []),
|
| 86 |
+
effectiveRequestModes:
|
| 87 |
+
requestModeHealthSummary?.effectiveRequestModes ?? (summary.credentialCount > 0 ? CHANNEL_REQUEST_MODES : []),
|
| 88 |
+
requestModeControls: CHANNEL_REQUEST_MODE_ADMIN_CONTROL,
|
| 89 |
+
requestModeHealth: requestModeHealthSummary?.modes ?? [],
|
| 90 |
+
requestModesByChannel: summary.channels.map((channel) => ({
|
| 91 |
+
channelId: channel.id,
|
| 92 |
+
requestModes: channel.requestModes
|
| 93 |
+
})),
|
| 94 |
+
effectiveRequestModesByChannel:
|
| 95 |
+
requestModeHealthSummary?.effectiveRequestModesByChannel ??
|
| 96 |
+
summary.channels.map((channel) => ({
|
| 97 |
+
channelId: channel.id,
|
| 98 |
+
requestModes: channel.requestModes
|
| 99 |
+
}))
|
| 100 |
+
},
|
| 101 |
upstreamProfile,
|
| 102 |
imageTransport: summarizeOpenAIImageTransport(process.env),
|
| 103 |
providerManifests,
|
src/lib/agent-api-contracts.test.ts
CHANGED
|
@@ -524,6 +524,35 @@ describe('buildAgentCapabilities', () => {
|
|
| 524 |
required_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'],
|
| 525 |
missing_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL']
|
| 526 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
assert.deepEqual(capabilities.supported.streaming_strategies, [
|
| 528 |
'off',
|
| 529 |
'auto',
|
|
@@ -560,6 +589,28 @@ describe('buildAgentCapabilities', () => {
|
|
| 560 |
assert.match(capabilities.agent_jobs.current_guidance, /job/);
|
| 561 |
});
|
| 562 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 563 |
it('reports Matsca server-channel upload and image-count limits in Agent capabilities', () => {
|
| 564 |
const capabilities = buildAgentCapabilities({
|
| 565 |
OPENAI_CHANNEL_1_ID: 'matsca',
|
|
@@ -820,6 +871,25 @@ describe('buildAgentCapabilities', () => {
|
|
| 820 |
assert.ok('AgentErrorDiagnostics' in document.components.schemas);
|
| 821 |
assert.ok('AgentImageResponseTiming' in document.components.schemas);
|
| 822 |
assert.ok('AgentImageResponseExecution' in document.components.schemas);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 823 |
assert.ok('UpstreamRequestHeaderSummary' in document.components.schemas);
|
| 824 |
assert.ok('AgentRequestDiagnosticsCapabilities' in document.components.schemas);
|
| 825 |
assert.ok('AgentRequestDiagnosticsRetention' in document.components.schemas);
|
|
@@ -998,6 +1068,20 @@ describe('buildAgentCapabilities', () => {
|
|
| 998 |
'responses-image-generation'
|
| 999 |
]);
|
| 1000 |
assert.ok(capabilityProperties.supported.properties.image_backend_requirements);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1001 |
assert.deepEqual(
|
| 1002 |
document.components.schemas.AgentStreamingCapabilities.properties.upstream_sse.properties.request_fields
|
| 1003 |
.const,
|
|
@@ -1061,11 +1145,29 @@ describe('buildAgentCapabilities', () => {
|
|
| 1061 |
assert.equal(document.components.schemas.AgentRoutingRules.required.includes('long_image_recovery'), true);
|
| 1062 |
assert.match(document.components.schemas.EditRequest.description, /\/api\/images/);
|
| 1063 |
assert.ok('upstream_event_type' in document.components.schemas.AgentErrorDiagnostics.properties);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1064 |
assert.ok('partial_image_count' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1065 |
assert.ok('transport_error_kind' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1066 |
assert.ok('retry_after_ms' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1067 |
assert.ok('cooldown_until' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1068 |
assert.ok('cooldown_target' in document.components.schemas.AgentErrorDiagnostics.properties);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1069 |
assert.equal(document.components.schemas.ResultFeedback.properties.note.maxLength, 500);
|
| 1070 |
});
|
| 1071 |
|
|
|
|
| 524 |
required_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL'],
|
| 525 |
missing_env: ['ENABLE_RESPONSES_IMAGE_BACKEND', 'OPENAI_RESPONSES_API_MODEL']
|
| 526 |
});
|
| 527 |
+
assert.deepEqual(capabilities.supported.request_modes, [
|
| 528 |
+
'images-non-stream',
|
| 529 |
+
'images-sse',
|
| 530 |
+
'responses-non-stream',
|
| 531 |
+
'responses-sse'
|
| 532 |
+
]);
|
| 533 |
+
assert.deepEqual(capabilities.request_mode_controls, {
|
| 534 |
+
source: 'admin_env_whitelist',
|
| 535 |
+
global_env: 'OPENAI_UPSTREAM_REQUEST_MODES',
|
| 536 |
+
channel_env_pattern: 'OPENAI_CHANNEL_N_REQUEST_MODES',
|
| 537 |
+
mutable_at_runtime: false,
|
| 538 |
+
agent_client_policy: 'diagnostics_only',
|
| 539 |
+
final_gate_command:
|
| 540 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable',
|
| 541 |
+
smoke_gate_commands: {
|
| 542 |
+
'images-non-stream': [
|
| 543 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case original-images-json --allow-billable'
|
| 544 |
+
],
|
| 545 |
+
'images-sse': [
|
| 546 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-images-sse --allow-billable'
|
| 547 |
+
],
|
| 548 |
+
'responses-non-stream': [
|
| 549 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-responses-json --allow-billable'
|
| 550 |
+
],
|
| 551 |
+
'responses-sse': [
|
| 552 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
|
| 553 |
+
]
|
| 554 |
+
}
|
| 555 |
+
});
|
| 556 |
assert.deepEqual(capabilities.supported.streaming_strategies, [
|
| 557 |
'off',
|
| 558 |
'auto',
|
|
|
|
| 589 |
assert.match(capabilities.agent_jobs.current_guidance, /job/);
|
| 590 |
});
|
| 591 |
|
| 592 |
+
it('reports configured server-channel request modes in Agent capabilities', () => {
|
| 593 |
+
const capabilities = buildAgentCapabilities({
|
| 594 |
+
OPENAI_CHANNEL_1_ID: 'images',
|
| 595 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1',
|
| 596 |
+
OPENAI_CHANNEL_1_API_KEYS: 'configured',
|
| 597 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-json,images-sse'
|
| 598 |
+
});
|
| 599 |
+
|
| 600 |
+
assert.deepEqual(capabilities.upstream_request_headers.channels, [
|
| 601 |
+
{
|
| 602 |
+
id: 'images',
|
| 603 |
+
request_modes: ['images-non-stream', 'images-sse'],
|
| 604 |
+
request_headers: {
|
| 605 |
+
user_agent_effective: 'gpt-image-playground/2.1.0',
|
| 606 |
+
has_extra_headers: false,
|
| 607 |
+
allowed_header_names: ['user-agent', 'x-app-id', 'x-app-secret'],
|
| 608 |
+
configured_header_names: []
|
| 609 |
+
}
|
| 610 |
+
}
|
| 611 |
+
]);
|
| 612 |
+
});
|
| 613 |
+
|
| 614 |
it('reports Matsca server-channel upload and image-count limits in Agent capabilities', () => {
|
| 615 |
const capabilities = buildAgentCapabilities({
|
| 616 |
OPENAI_CHANNEL_1_ID: 'matsca',
|
|
|
|
| 871 |
assert.ok('AgentErrorDiagnostics' in document.components.schemas);
|
| 872 |
assert.ok('AgentImageResponseTiming' in document.components.schemas);
|
| 873 |
assert.ok('AgentImageResponseExecution' in document.components.schemas);
|
| 874 |
+
assert.ok('ChannelRequestModeDecision' in document.components.schemas);
|
| 875 |
+
assert.deepEqual(document.components.schemas.AgentImageResponseExecution.properties.channel_request_mode.enum, [
|
| 876 |
+
'images-non-stream',
|
| 877 |
+
'images-sse',
|
| 878 |
+
'responses-non-stream',
|
| 879 |
+
'responses-sse'
|
| 880 |
+
]);
|
| 881 |
+
assert.equal(
|
| 882 |
+
document.components.schemas.AgentImageResponseExecution.properties.channel_request_mode_fallback_applied.type,
|
| 883 |
+
'boolean'
|
| 884 |
+
);
|
| 885 |
+
assert.equal(
|
| 886 |
+
document.components.schemas.AgentImageResponseExecution.properties.route_decision.$ref,
|
| 887 |
+
'#/components/schemas/ChannelRequestModeDecision'
|
| 888 |
+
);
|
| 889 |
+
assert.equal(
|
| 890 |
+
document.components.schemas.ChannelRequestModeDecision.properties.requested_backend.enum.includes('images-api'),
|
| 891 |
+
true
|
| 892 |
+
);
|
| 893 |
assert.ok('UpstreamRequestHeaderSummary' in document.components.schemas);
|
| 894 |
assert.ok('AgentRequestDiagnosticsCapabilities' in document.components.schemas);
|
| 895 |
assert.ok('AgentRequestDiagnosticsRetention' in document.components.schemas);
|
|
|
|
| 1068 |
'responses-image-generation'
|
| 1069 |
]);
|
| 1070 |
assert.ok(capabilityProperties.supported.properties.image_backend_requirements);
|
| 1071 |
+
assert.deepEqual(capabilityProperties.supported.properties.request_modes.items.enum, [
|
| 1072 |
+
'images-non-stream',
|
| 1073 |
+
'images-sse',
|
| 1074 |
+
'responses-non-stream',
|
| 1075 |
+
'responses-sse'
|
| 1076 |
+
]);
|
| 1077 |
+
assert.equal(
|
| 1078 |
+
document.components.schemas.AgentRequestModeControls.properties.agent_client_policy.const,
|
| 1079 |
+
'diagnostics_only'
|
| 1080 |
+
);
|
| 1081 |
+
assert.equal(
|
| 1082 |
+
document.components.schemas.AgentRequestModeControls.properties.channel_env_pattern.type,
|
| 1083 |
+
'string'
|
| 1084 |
+
);
|
| 1085 |
assert.deepEqual(
|
| 1086 |
document.components.schemas.AgentStreamingCapabilities.properties.upstream_sse.properties.request_fields
|
| 1087 |
.const,
|
|
|
|
| 1145 |
assert.equal(document.components.schemas.AgentRoutingRules.required.includes('long_image_recovery'), true);
|
| 1146 |
assert.match(document.components.schemas.EditRequest.description, /\/api\/images/);
|
| 1147 |
assert.ok('upstream_event_type' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1148 |
+
assert.deepEqual(document.components.schemas.AgentErrorDiagnostics.properties.channel_request_mode.enum, [
|
| 1149 |
+
'images-non-stream',
|
| 1150 |
+
'images-sse',
|
| 1151 |
+
'responses-non-stream',
|
| 1152 |
+
'responses-sse'
|
| 1153 |
+
]);
|
| 1154 |
+
assert.equal(
|
| 1155 |
+
document.components.schemas.AgentErrorDiagnostics.properties.channel_request_mode_fallback_applied.type,
|
| 1156 |
+
'boolean'
|
| 1157 |
+
);
|
| 1158 |
+
assert.equal(
|
| 1159 |
+
document.components.schemas.AgentErrorDiagnostics.properties.route_decision.$ref,
|
| 1160 |
+
'#/components/schemas/ChannelRequestModeDecision'
|
| 1161 |
+
);
|
| 1162 |
assert.ok('partial_image_count' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1163 |
assert.ok('transport_error_kind' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1164 |
assert.ok('retry_after_ms' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1165 |
assert.ok('cooldown_until' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1166 |
assert.ok('cooldown_target' in document.components.schemas.AgentErrorDiagnostics.properties);
|
| 1167 |
+
assert.deepEqual(
|
| 1168 |
+
document.components.schemas.AgentErrorDiagnostics.properties.cooldown_target.properties.request_mode.enum,
|
| 1169 |
+
['images-non-stream', 'images-sse', 'responses-non-stream', 'responses-sse']
|
| 1170 |
+
);
|
| 1171 |
assert.equal(document.components.schemas.ResultFeedback.properties.note.maxLength, 500);
|
| 1172 |
});
|
| 1173 |
|
src/lib/agent-api-contracts.ts
CHANGED
|
@@ -2,6 +2,12 @@ import { AGENT_ENDPOINTS, AGENT_JOB_ENDPOINTS } from './agent-api-paths.mjs';
|
|
| 2 |
import type { AgentErrorDiagnostics } from './api-error-response';
|
| 3 |
import { readAppLogRetentionMetadata, type AppLogRetentionMetadata } from './app-log-retention';
|
| 4 |
import { getChannelPoolSummary, parseChannelPoolConfig } from './channel-router';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import {
|
| 6 |
MAX_IMAGE_COUNT,
|
| 7 |
MAX_PROMPT_LENGTH,
|
|
@@ -174,6 +180,9 @@ export type AgentImageResponseExecution = {
|
|
| 174 |
image_backend: ImageGenerationBackend;
|
| 175 |
stream_mode: ImageStreamMode;
|
| 176 |
streaming_strategy: ImageStreamingStrategy;
|
|
|
|
|
|
|
|
|
|
| 177 |
selected_channel_id?: string;
|
| 178 |
upstream_host?: string;
|
| 179 |
request_headers: UpstreamRequestHeaderSummary;
|
|
@@ -231,9 +240,19 @@ export type AgentCapabilities = {
|
|
| 231 |
default: UpstreamRequestHeaderSummary;
|
| 232 |
channels: Array<{
|
| 233 |
id: string;
|
|
|
|
| 234 |
request_headers: UpstreamRequestHeaderSummary;
|
| 235 |
}>;
|
| 236 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
defaults: {
|
| 238 |
model: GptImageModel;
|
| 239 |
response_mode: AgentResponseMode;
|
|
@@ -366,6 +385,7 @@ export type AgentCapabilities = {
|
|
| 366 |
image_backends: readonly ImageGenerationBackend[];
|
| 367 |
enabled_image_backends: readonly ImageGenerationBackend[];
|
| 368 |
image_backend_requirements: Record<ImageGenerationBackend, ImageBackendRuntimeRequirement>;
|
|
|
|
| 369 |
streaming_strategies: readonly ImageStreamingStrategy[];
|
| 370 |
stream_modes: readonly ImageStreamMode[];
|
| 371 |
};
|
|
@@ -909,6 +929,7 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 909 |
image_transport: summarizeOpenAIImageTransport(env),
|
| 910 |
upstream_profile: upstreamLimits.summary,
|
| 911 |
upstream_request_headers: buildAgentUpstreamRequestHeadersCapabilities(env),
|
|
|
|
| 912 |
defaults: {
|
| 913 |
model: 'gpt-image-2',
|
| 914 |
response_mode: 'path',
|
|
@@ -1141,6 +1162,7 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 1141 |
image_backends: AGENT_IMAGE_BACKENDS,
|
| 1142 |
enabled_image_backends: enabledImageBackends,
|
| 1143 |
image_backend_requirements: imageBackendRequirements,
|
|
|
|
| 1144 |
streaming_strategies: AGENT_STREAMING_STRATEGIES,
|
| 1145 |
stream_modes: AGENT_STREAM_MODES
|
| 1146 |
},
|
|
@@ -1158,12 +1180,25 @@ export function buildAgentCapabilities(env: Record<string, string | undefined>):
|
|
| 1158 |
};
|
| 1159 |
}
|
| 1160 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1161 |
function buildAgentUpstreamRequestHeadersCapabilities(env: Record<string, string | undefined>): AgentCapabilities['upstream_request_headers'] {
|
| 1162 |
const channelSummary = getChannelPoolSummary(parseChannelPoolConfig(env));
|
| 1163 |
return {
|
| 1164 |
default: summarizeUpstreamRequestHeaders(undefined, env),
|
| 1165 |
channels: channelSummary.channels.map((channel) => ({
|
| 1166 |
id: channel.id,
|
|
|
|
| 1167 |
request_headers: channel.requestHeaders
|
| 1168 |
}))
|
| 1169 |
};
|
|
|
|
| 2 |
import type { AgentErrorDiagnostics } from './api-error-response';
|
| 3 |
import { readAppLogRetentionMetadata, type AppLogRetentionMetadata } from './app-log-retention';
|
| 4 |
import { getChannelPoolSummary, parseChannelPoolConfig } from './channel-router';
|
| 5 |
+
import {
|
| 6 |
+
CHANNEL_REQUEST_MODES,
|
| 7 |
+
CHANNEL_REQUEST_MODE_ADMIN_CONTROL,
|
| 8 |
+
type ChannelRequestMode,
|
| 9 |
+
type ChannelRequestModeDecision
|
| 10 |
+
} from './channel-request-mode';
|
| 11 |
import {
|
| 12 |
MAX_IMAGE_COUNT,
|
| 13 |
MAX_PROMPT_LENGTH,
|
|
|
|
| 180 |
image_backend: ImageGenerationBackend;
|
| 181 |
stream_mode: ImageStreamMode;
|
| 182 |
streaming_strategy: ImageStreamingStrategy;
|
| 183 |
+
channel_request_mode?: ChannelRequestMode;
|
| 184 |
+
channel_request_mode_fallback_applied?: boolean;
|
| 185 |
+
route_decision?: ChannelRequestModeDecision;
|
| 186 |
selected_channel_id?: string;
|
| 187 |
upstream_host?: string;
|
| 188 |
request_headers: UpstreamRequestHeaderSummary;
|
|
|
|
| 240 |
default: UpstreamRequestHeaderSummary;
|
| 241 |
channels: Array<{
|
| 242 |
id: string;
|
| 243 |
+
request_modes: readonly ChannelRequestMode[];
|
| 244 |
request_headers: UpstreamRequestHeaderSummary;
|
| 245 |
}>;
|
| 246 |
};
|
| 247 |
+
request_mode_controls: {
|
| 248 |
+
source: 'admin_env_whitelist';
|
| 249 |
+
global_env: string;
|
| 250 |
+
channel_env_pattern: string;
|
| 251 |
+
mutable_at_runtime: false;
|
| 252 |
+
agent_client_policy: 'diagnostics_only';
|
| 253 |
+
final_gate_command: string;
|
| 254 |
+
smoke_gate_commands: Record<ChannelRequestMode, readonly string[]>;
|
| 255 |
+
};
|
| 256 |
defaults: {
|
| 257 |
model: GptImageModel;
|
| 258 |
response_mode: AgentResponseMode;
|
|
|
|
| 385 |
image_backends: readonly ImageGenerationBackend[];
|
| 386 |
enabled_image_backends: readonly ImageGenerationBackend[];
|
| 387 |
image_backend_requirements: Record<ImageGenerationBackend, ImageBackendRuntimeRequirement>;
|
| 388 |
+
request_modes: readonly ChannelRequestMode[];
|
| 389 |
streaming_strategies: readonly ImageStreamingStrategy[];
|
| 390 |
stream_modes: readonly ImageStreamMode[];
|
| 391 |
};
|
|
|
|
| 929 |
image_transport: summarizeOpenAIImageTransport(env),
|
| 930 |
upstream_profile: upstreamLimits.summary,
|
| 931 |
upstream_request_headers: buildAgentUpstreamRequestHeadersCapabilities(env),
|
| 932 |
+
request_mode_controls: buildAgentRequestModeControlsCapabilities(),
|
| 933 |
defaults: {
|
| 934 |
model: 'gpt-image-2',
|
| 935 |
response_mode: 'path',
|
|
|
|
| 1162 |
image_backends: AGENT_IMAGE_BACKENDS,
|
| 1163 |
enabled_image_backends: enabledImageBackends,
|
| 1164 |
image_backend_requirements: imageBackendRequirements,
|
| 1165 |
+
request_modes: CHANNEL_REQUEST_MODES,
|
| 1166 |
streaming_strategies: AGENT_STREAMING_STRATEGIES,
|
| 1167 |
stream_modes: AGENT_STREAM_MODES
|
| 1168 |
},
|
|
|
|
| 1180 |
};
|
| 1181 |
}
|
| 1182 |
|
| 1183 |
+
function buildAgentRequestModeControlsCapabilities(): AgentCapabilities['request_mode_controls'] {
|
| 1184 |
+
return {
|
| 1185 |
+
source: CHANNEL_REQUEST_MODE_ADMIN_CONTROL.source,
|
| 1186 |
+
global_env: CHANNEL_REQUEST_MODE_ADMIN_CONTROL.globalEnv,
|
| 1187 |
+
channel_env_pattern: CHANNEL_REQUEST_MODE_ADMIN_CONTROL.channelEnvPattern,
|
| 1188 |
+
mutable_at_runtime: CHANNEL_REQUEST_MODE_ADMIN_CONTROL.mutableAtRuntime,
|
| 1189 |
+
agent_client_policy: 'diagnostics_only',
|
| 1190 |
+
final_gate_command: CHANNEL_REQUEST_MODE_ADMIN_CONTROL.finalGateCommand,
|
| 1191 |
+
smoke_gate_commands: CHANNEL_REQUEST_MODE_ADMIN_CONTROL.smokeGateCommands
|
| 1192 |
+
};
|
| 1193 |
+
}
|
| 1194 |
+
|
| 1195 |
function buildAgentUpstreamRequestHeadersCapabilities(env: Record<string, string | undefined>): AgentCapabilities['upstream_request_headers'] {
|
| 1196 |
const channelSummary = getChannelPoolSummary(parseChannelPoolConfig(env));
|
| 1197 |
return {
|
| 1198 |
default: summarizeUpstreamRequestHeaders(undefined, env),
|
| 1199 |
channels: channelSummary.channels.map((channel) => ({
|
| 1200 |
id: channel.id,
|
| 1201 |
+
request_modes: channel.requestModes,
|
| 1202 |
request_headers: channel.requestHeaders
|
| 1203 |
}))
|
| 1204 |
};
|
src/lib/agent-channel-request-mode.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AgentApiError } from './api-error-response';
|
| 2 |
+
import {
|
| 3 |
+
isStreamingChannelRequestMode,
|
| 4 |
+
resolveChannelRequestMode,
|
| 5 |
+
type ChannelRequestMode,
|
| 6 |
+
type ChannelRequestModeBackend,
|
| 7 |
+
type ChannelRequestModeDecision
|
| 8 |
+
} from './channel-request-mode';
|
| 9 |
+
import type { ChannelCredential } from './channel-router';
|
| 10 |
+
import { RequestValidationError } from './image-request-utils';
|
| 11 |
+
import {
|
| 12 |
+
resolveImageStreamEnabled,
|
| 13 |
+
type ImageStreamMode,
|
| 14 |
+
type ImageStreamingStrategy
|
| 15 |
+
} from './image-upstream-strategy';
|
| 16 |
+
import type { getServerChannelState } from './server-channel-router';
|
| 17 |
+
import { readAffinityKey } from './server-runtime';
|
| 18 |
+
|
| 19 |
+
export type AgentChannelRequestModePlan = {
|
| 20 |
+
imageBackend: ChannelRequestModeBackend;
|
| 21 |
+
preferred: ChannelRequestMode;
|
| 22 |
+
fallback?: ChannelRequestMode;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
export type AgentChannelSelection = {
|
| 26 |
+
selectedCredential?: ChannelCredential;
|
| 27 |
+
requestMode: ChannelRequestMode;
|
| 28 |
+
fallbackApplied: boolean;
|
| 29 |
+
noChannelReason?: string;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export function createAgentChannelRequestModePlan(input: {
|
| 33 |
+
imageBackend: ChannelRequestModeBackend;
|
| 34 |
+
streamMode: ImageStreamMode;
|
| 35 |
+
streamingStrategy: ImageStreamingStrategy;
|
| 36 |
+
}): AgentChannelRequestModePlan {
|
| 37 |
+
const preferred = resolveChannelRequestMode({
|
| 38 |
+
imageBackend: input.imageBackend,
|
| 39 |
+
streamEnabled: resolveStaticAgentStreamEnabled(input)
|
| 40 |
+
});
|
| 41 |
+
if (input.streamMode !== 'auto' || !isStreamingChannelRequestMode(preferred)) {
|
| 42 |
+
return { imageBackend: input.imageBackend, preferred };
|
| 43 |
+
}
|
| 44 |
+
return {
|
| 45 |
+
imageBackend: input.imageBackend,
|
| 46 |
+
preferred,
|
| 47 |
+
fallback: resolveChannelRequestMode({ imageBackend: input.imageBackend, streamEnabled: false })
|
| 48 |
+
};
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export function selectAgentChannelCredential(input: {
|
| 52 |
+
router: ReturnType<typeof getServerChannelState>['router'];
|
| 53 |
+
headers: Headers;
|
| 54 |
+
requestModePlan: AgentChannelRequestModePlan;
|
| 55 |
+
}): AgentChannelSelection {
|
| 56 |
+
try {
|
| 57 |
+
return selectAgentChannelForMode(input, input.requestModePlan.preferred, false);
|
| 58 |
+
} catch (error) {
|
| 59 |
+
if (!input.requestModePlan.fallback || !(error instanceof RequestValidationError)) {
|
| 60 |
+
throw normalizeChannelSelectionError(error, input.requestModePlan, false);
|
| 61 |
+
}
|
| 62 |
+
try {
|
| 63 |
+
return selectAgentChannelForMode(input, input.requestModePlan.fallback, true);
|
| 64 |
+
} catch (fallbackError) {
|
| 65 |
+
throw normalizeChannelSelectionError(fallbackError, input.requestModePlan, true);
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
export function buildAgentChannelRequestModeDecision(input: {
|
| 71 |
+
requestModePlan: AgentChannelRequestModePlan;
|
| 72 |
+
selection: AgentChannelSelection;
|
| 73 |
+
selectedCredential?: ChannelCredential;
|
| 74 |
+
upstreamHost?: string;
|
| 75 |
+
}): ChannelRequestModeDecision {
|
| 76 |
+
const selectedChannelId = input.selectedCredential?.channelId ?? input.selection.selectedCredential?.channelId;
|
| 77 |
+
return {
|
| 78 |
+
requested_backend: input.requestModePlan.imageBackend,
|
| 79 |
+
preferred_channel_request_mode: input.requestModePlan.preferred,
|
| 80 |
+
...(input.requestModePlan.fallback ? { fallback_channel_request_mode: input.requestModePlan.fallback } : {}),
|
| 81 |
+
selected_channel_request_mode: input.selection.requestMode,
|
| 82 |
+
fallback_applied: input.selection.fallbackApplied,
|
| 83 |
+
...(selectedChannelId ? { selected_channel_id: selectedChannelId } : {}),
|
| 84 |
+
...(input.upstreamHost ? { upstream_host: input.upstreamHost } : {}),
|
| 85 |
+
...(input.selection.noChannelReason ? { no_channel_reason: input.selection.noChannelReason } : {})
|
| 86 |
+
};
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
function resolveStaticAgentStreamEnabled(input: {
|
| 90 |
+
imageBackend: ChannelRequestModeBackend;
|
| 91 |
+
streamMode: ImageStreamMode;
|
| 92 |
+
streamingStrategy: ImageStreamingStrategy;
|
| 93 |
+
}): boolean {
|
| 94 |
+
if (input.streamMode === 'non_stream') return false;
|
| 95 |
+
if (input.streamMode === 'auto' && input.streamingStrategy === 'off') return false;
|
| 96 |
+
return resolveImageStreamEnabled({
|
| 97 |
+
imageBackend: input.imageBackend,
|
| 98 |
+
requestedStream: true,
|
| 99 |
+
streamingStrategy: input.streamingStrategy
|
| 100 |
+
});
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function selectAgentChannelForMode(
|
| 104 |
+
input: {
|
| 105 |
+
router: ReturnType<typeof getServerChannelState>['router'];
|
| 106 |
+
headers: Headers;
|
| 107 |
+
},
|
| 108 |
+
requestMode: ChannelRequestMode,
|
| 109 |
+
fallbackApplied: boolean
|
| 110 |
+
): AgentChannelSelection {
|
| 111 |
+
return {
|
| 112 |
+
selectedCredential: input.router?.select({
|
| 113 |
+
affinityKey: readAffinityKey(input.headers),
|
| 114 |
+
requestMode
|
| 115 |
+
}),
|
| 116 |
+
requestMode,
|
| 117 |
+
fallbackApplied
|
| 118 |
+
};
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
function normalizeChannelSelectionError(
|
| 122 |
+
error: unknown,
|
| 123 |
+
requestModePlan: AgentChannelRequestModePlan,
|
| 124 |
+
fallbackApplied: boolean
|
| 125 |
+
): unknown {
|
| 126 |
+
if (!(error instanceof RequestValidationError)) {
|
| 127 |
+
return error;
|
| 128 |
+
}
|
| 129 |
+
const requestMode = fallbackApplied
|
| 130 |
+
? (requestModePlan.fallback ?? requestModePlan.preferred)
|
| 131 |
+
: requestModePlan.preferred;
|
| 132 |
+
return new AgentApiError({
|
| 133 |
+
code: error.status >= 500 ? 'configuration_error' : 'validation_error',
|
| 134 |
+
message: error.message,
|
| 135 |
+
status: error.status,
|
| 136 |
+
retryable: false,
|
| 137 |
+
details: error.details,
|
| 138 |
+
diagnostics: {
|
| 139 |
+
channel_request_mode: requestMode,
|
| 140 |
+
channel_request_mode_fallback_applied: fallbackApplied,
|
| 141 |
+
route_decision: buildAgentChannelRequestModeDecision({
|
| 142 |
+
requestModePlan,
|
| 143 |
+
selection: {
|
| 144 |
+
requestMode,
|
| 145 |
+
fallbackApplied,
|
| 146 |
+
noChannelReason: error.message
|
| 147 |
+
}
|
| 148 |
+
})
|
| 149 |
+
}
|
| 150 |
+
});
|
| 151 |
+
}
|
src/lib/agent-image-service.ts
CHANGED
|
@@ -7,6 +7,12 @@ import {
|
|
| 7 |
validateAgentGenerateRequest
|
| 8 |
} from './agent-api-contracts';
|
| 9 |
import { AGENT_ENDPOINTS } from './agent-api-paths.mjs';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
import { assertArtifactFilepathAllowed, deleteArtifactFileIfAllowed } from './agent-file-utils';
|
| 11 |
import {
|
| 12 |
artifactRecordToResponseItem,
|
|
@@ -20,10 +26,20 @@ import {
|
|
| 20 |
type BeginAgentRequestResult,
|
| 21 |
type AgentStateStore
|
| 22 |
} from './agent-state-store';
|
| 23 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
import type { AgentErrorDiagnostics } from './api-error-response';
|
| 25 |
import { appLogger } from './app-logger';
|
| 26 |
import type { ChannelCapacityLease } from './channel-capacity-queue';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
import {
|
| 28 |
type ChannelCredential,
|
| 29 |
type ChannelFailureReport,
|
|
@@ -53,13 +69,7 @@ import {
|
|
| 53 |
MissingOpenAiImageDataError,
|
| 54 |
persistOpenAiImages as persistSharedOpenAiImages
|
| 55 |
} from './image-service';
|
| 56 |
-
import {
|
| 57 |
-
parseImageStreamModeValue,
|
| 58 |
-
parseImageStreamingStrategyValue,
|
| 59 |
-
resolveImageStreamEnabled,
|
| 60 |
-
type ImageStreamMode,
|
| 61 |
-
type ImageStreamingStrategy
|
| 62 |
-
} from './image-upstream-strategy';
|
| 63 |
import {
|
| 64 |
readImageUpstreamProfile,
|
| 65 |
mergeUpstreamHeadersWithFixed,
|
|
@@ -68,21 +78,27 @@ import {
|
|
| 68 |
type PartialImagesCount,
|
| 69 |
type UpstreamRequestHeaders
|
| 70 |
} from './image-upstream-profile';
|
| 71 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
import { createImagesApiGenerateStream } from './images-api-stream';
|
|
|
|
| 73 |
import {
|
| 74 |
createResponsesImageStream,
|
| 75 |
generateImageWithResponsesBackend,
|
| 76 |
type ResponsesImageGenerateInput
|
| 77 |
} from './responses-image-backend';
|
| 78 |
-
import { buildOpenAIImageRequestOptions, createOpenAIImageClientOptions } from './openai-image-transport';
|
| 79 |
import { getServerChannelState } from './server-channel-router';
|
|
|
|
| 80 |
import type { StreamingAvailabilityKey } from './streaming-availability';
|
| 81 |
-
import { readAffinityKey, readBooleanEnv } from './server-runtime';
|
| 82 |
import crypto from 'crypto';
|
| 83 |
import fs from 'fs/promises';
|
| 84 |
-
import OpenAI from 'openai';
|
| 85 |
import { NextResponse } from 'next/server';
|
|
|
|
| 86 |
|
| 87 |
export type AgentRequestExecutionResult = {
|
| 88 |
response: AgentImageResponse;
|
|
@@ -93,6 +109,9 @@ export type AgentRequestExecutionResult = {
|
|
| 93 |
type CredentialContext = {
|
| 94 |
openai: OpenAI;
|
| 95 |
selectedCredential?: ChannelCredential;
|
|
|
|
|
|
|
|
|
|
| 96 |
baseUrl?: string;
|
| 97 |
apiKey: string;
|
| 98 |
upstreamProfile: ImageUpstreamProfile;
|
|
@@ -123,6 +142,7 @@ type AgentStreamOptions = {
|
|
| 123 |
streamingStrategy: ImageStreamingStrategy;
|
| 124 |
partialImages: PartialImagesCount;
|
| 125 |
selectedCredential?: ChannelCredential;
|
|
|
|
| 126 |
};
|
| 127 |
|
| 128 |
type AgentEditStreamRequest = {
|
|
@@ -141,6 +161,9 @@ type AgentExecutionMetadata = {
|
|
| 141 |
imageBackend: AgentImageResponseExecution['image_backend'];
|
| 142 |
streamMode: ImageStreamMode;
|
| 143 |
streamingStrategy: ImageStreamingStrategy;
|
|
|
|
|
|
|
|
|
|
| 144 |
selectedCredential?: ChannelCredential;
|
| 145 |
};
|
| 146 |
|
|
@@ -242,16 +265,32 @@ export async function agentBeginResultResponse(
|
|
| 242 |
}
|
| 243 |
|
| 244 |
export function prepareAgentGenerate(request: AgentGenerateRequest, headers: Headers): AgentGeneratePreparation {
|
| 245 |
-
const credentialContext = createOpenAiClient(headers);
|
| 246 |
validateAgentGenerateAgainstUpstreamProfile(request, credentialContext.upstreamProfile);
|
| 247 |
return { credentialContext };
|
| 248 |
}
|
| 249 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
export async function prepareAgentEdit(formData: FormData, headers: Headers): Promise<AgentEditPreparation> {
|
| 251 |
const prompt = readRequiredText(formData, 'prompt');
|
| 252 |
const model = readModel(formData);
|
| 253 |
assertImageFilesPresent(formData);
|
| 254 |
-
const credentialContext = createOpenAiClient(headers);
|
| 255 |
const n = readCount(
|
| 256 |
formData,
|
| 257 |
'n',
|
|
@@ -275,10 +314,7 @@ export async function prepareAgentEdit(formData: FormData, headers: Headers): Pr
|
|
| 275 |
return { credentialContext, prompt, model, n, size, quality, responseMode, streamRequest, imageFiles, maskFile };
|
| 276 |
}
|
| 277 |
|
| 278 |
-
function buildOpenAiRequestOptions(
|
| 279 |
-
context: CredentialContext,
|
| 280 |
-
abortSignal?: AbortSignal
|
| 281 |
-
): OpenAI.RequestOptions {
|
| 282 |
return buildOpenAIImageRequestOptions({
|
| 283 |
abortSignal,
|
| 284 |
headers: mergeUpstreamHeadersWithFixed(context.upstreamHeaders, {})
|
|
@@ -350,17 +386,15 @@ export async function executeAgentGenerate(options: {
|
|
| 350 |
transport?: AgentExecutionTransportContext;
|
| 351 |
abortSignal?: AbortSignal;
|
| 352 |
}): Promise<AgentRequestExecutionResult> {
|
| 353 |
-
const credentialContext =
|
|
|
|
|
|
|
| 354 |
const startedAtMs = Date.now();
|
| 355 |
const startedAt = isoDate(new Date(startedAtMs));
|
| 356 |
let channelLease: ChannelCapacityLease | undefined;
|
| 357 |
try {
|
| 358 |
channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
|
| 359 |
-
const result = await executeAgentGenerateUpstream(
|
| 360 |
-
options.request,
|
| 361 |
-
credentialContext,
|
| 362 |
-
options.abortSignal
|
| 363 |
-
);
|
| 364 |
channelLease?.release();
|
| 365 |
channelLease = undefined;
|
| 366 |
return await persistOpenAiImages({
|
|
@@ -388,12 +422,19 @@ export async function executeAgentGenerate(options: {
|
|
| 388 |
imageBackend: options.request.image_backend,
|
| 389 |
streamMode: options.request.stream_mode,
|
| 390 |
streamingStrategy: options.request.streaming_strategy,
|
|
|
|
|
|
|
|
|
|
| 391 |
selectedCredential: credentialContext.selectedCredential
|
| 392 |
},
|
| 393 |
abortSignal: options.abortSignal
|
| 394 |
});
|
| 395 |
} catch (error) {
|
| 396 |
-
const failureReport = reportServerCredentialFailure(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs, failureReport));
|
| 398 |
} finally {
|
| 399 |
channelLease?.release();
|
|
@@ -412,7 +453,8 @@ async function executeAgentGenerateUpstream(
|
|
| 412 |
streamMode: request.stream_mode,
|
| 413 |
streamingStrategy: request.streaming_strategy,
|
| 414 |
partialImages: request.partial_images,
|
| 415 |
-
selectedCredential: credentialContext.selectedCredential
|
|
|
|
| 416 |
};
|
| 417 |
if (request.image_backend === 'responses-image-generation') {
|
| 418 |
return executeAgentResponsesGenerate(request, credentialContext, abortSignal);
|
|
@@ -475,7 +517,10 @@ function validateAgentGenerateAgainstUpstreamProfile(
|
|
| 475 |
422
|
| 476 |
);
|
| 477 |
}
|
| 478 |
-
if (
|
|
|
|
|
|
|
|
|
|
| 479 |
throw new RequestValidationError(
|
| 480 |
`partial_images 必须在 ${upstreamProfile.partialImages.min} 到 ${upstreamProfile.partialImages.max} 之间。`,
|
| 481 |
422
|
|
@@ -528,7 +573,8 @@ async function executeAgentResponsesGenerate(
|
|
| 528 |
streamMode: request.stream_mode,
|
| 529 |
streamingStrategy: request.streaming_strategy,
|
| 530 |
partialImages: request.partial_images,
|
| 531 |
-
selectedCredential: credentialContext.selectedCredential
|
|
|
|
| 532 |
};
|
| 533 |
if (!shouldUseAgentUpstreamStream(streamOptions)) {
|
| 534 |
return generateImageWithResponsesBackend(input);
|
|
@@ -561,6 +607,7 @@ function shouldUseAgentUpstreamStream(input: AgentStreamOptions): boolean {
|
|
| 561 |
const key = createAgentStreamingAvailabilityKey(input);
|
| 562 |
const availability = getServerChannelState().streamingAvailability;
|
| 563 |
if (input.streamMode === 'non_stream') return false;
|
|
|
|
| 564 |
if (input.streamMode === 'auto' && availability.isUnavailable(key)) return false;
|
| 565 |
return resolveImageStreamEnabled({
|
| 566 |
imageBackend: input.imageBackend,
|
|
@@ -643,7 +690,7 @@ export async function executeAgentEdit(options: {
|
|
| 643 |
const startedAt = isoDate(new Date(startedAtMs));
|
| 644 |
let channelLease: ChannelCapacityLease | undefined;
|
| 645 |
try {
|
| 646 |
-
const preparation = options.preparation ?? await prepareAgentEdit(options.formData, options.headers);
|
| 647 |
credentialContext = preparation.credentialContext;
|
| 648 |
const editParams: OpenAI.Images.ImageEditParamsNonStreaming = {
|
| 649 |
model: preparation.model,
|
|
@@ -660,7 +707,8 @@ export async function executeAgentEdit(options: {
|
|
| 660 |
streamMode: preparation.streamRequest.streamMode,
|
| 661 |
streamingStrategy: preparation.streamRequest.streamingStrategy,
|
| 662 |
partialImages: preparation.streamRequest.partialImages,
|
| 663 |
-
selectedCredential: credentialContext.selectedCredential
|
|
|
|
| 664 |
};
|
| 665 |
channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
|
| 666 |
const result = shouldUseAgentUpstreamStream(streamOptions)
|
|
@@ -702,12 +750,19 @@ export async function executeAgentEdit(options: {
|
|
| 702 |
imageBackend: 'images-api',
|
| 703 |
streamMode: preparation.streamRequest.streamMode,
|
| 704 |
streamingStrategy: preparation.streamRequest.streamingStrategy,
|
|
|
|
|
|
|
|
|
|
| 705 |
selectedCredential: credentialContext.selectedCredential
|
| 706 |
},
|
| 707 |
abortSignal: options.abortSignal
|
| 708 |
});
|
| 709 |
} catch (error) {
|
| 710 |
-
const failureReport = reportServerCredentialFailure(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 711 |
throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs, failureReport));
|
| 712 |
} finally {
|
| 713 |
channelLease?.release();
|
|
@@ -895,9 +950,14 @@ export async function hydrateAgentReplayResponse(
|
|
| 895 |
};
|
| 896 |
}
|
| 897 |
|
| 898 |
-
function createOpenAiClient(headers: Headers): CredentialContext {
|
| 899 |
const serverChannelRouter = getServerChannelState().router;
|
| 900 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 901 |
const {
|
| 902 |
apiKey,
|
| 903 |
baseUrl,
|
|
@@ -912,12 +972,21 @@ function createOpenAiClient(headers: Headers): CredentialContext {
|
|
| 912 |
validateApiBaseUrl(baseUrl || '', {
|
| 913 |
allowedPlainHttpBaseUrls: readPlainHttpApiBaseUrlAllowlist(process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS)
|
| 914 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
if (!apiKey) {
|
| 916 |
throw new AgentApiError({
|
| 917 |
code: 'configuration_error',
|
| 918 |
message: '未配置服务端 API Key。请设置 OPENAI_API_KEY 或 OPENAI_CHANNEL_N_API_KEYS。',
|
| 919 |
status: 500,
|
| 920 |
-
retryable: false
|
|
|
|
|
|
|
|
|
|
| 921 |
});
|
| 922 |
}
|
| 923 |
return {
|
|
@@ -929,6 +998,9 @@ function createOpenAiClient(headers: Headers): CredentialContext {
|
|
| 929 |
})
|
| 930 |
),
|
| 931 |
selectedCredential: effectiveSelectedCredential,
|
|
|
|
|
|
|
|
|
|
| 932 |
baseUrl,
|
| 933 |
apiKey,
|
| 934 |
upstreamProfile:
|
|
@@ -950,19 +1022,27 @@ function buildAgentExecutionDiagnostics(
|
|
| 950 |
const upstreamHost = context?.baseUrl ? readUrlHost(context.baseUrl) : undefined;
|
| 951 |
return {
|
| 952 |
elapsed_ms: Date.now() - startedAtMs,
|
|
|
|
|
|
|
|
|
|
| 953 |
...(context?.selectedCredential?.channelId
|
| 954 |
? { selected_channel_id: context.selectedCredential.channelId }
|
| 955 |
: {}),
|
| 956 |
...(upstreamHost ? { upstream_host: upstreamHost } : {}),
|
| 957 |
-
...(failureReport?.cooldownApplied
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
};
|
| 967 |
}
|
| 968 |
|
|
@@ -976,13 +1056,17 @@ function readUrlHost(value: string): string | undefined {
|
|
| 976 |
|
| 977 |
function reportServerCredentialFailure(
|
| 978 |
credential: ChannelCredential | undefined,
|
| 979 |
-
error: unknown
|
|
|
|
| 980 |
): ChannelFailureReport | undefined {
|
| 981 |
const serverChannelRouter = getServerChannelState().router;
|
| 982 |
if (!credential || !serverChannelRouter) return undefined;
|
| 983 |
if (isChannelFailure(error)) {
|
| 984 |
-
const reason =
|
| 985 |
-
|
|
|
|
|
|
|
|
|
|
| 986 |
appLogger.warn(
|
| 987 |
report.cooldownApplied
|
| 988 |
? `Temporarily cooling down API channel: ${credential.channelId}`
|
|
@@ -992,8 +1076,11 @@ function reportServerCredentialFailure(
|
|
| 992 |
return report;
|
| 993 |
}
|
| 994 |
if (isCredentialFailure(error)) {
|
| 995 |
-
const reason =
|
| 996 |
-
|
|
|
|
|
|
|
|
|
|
| 997 |
appLogger.warn(
|
| 998 |
report.cooldownApplied
|
| 999 |
? `Temporarily cooling down API channel credential: ${credential.channelId}/${credential.id}`
|
|
@@ -1126,6 +1213,12 @@ function buildAgentImageExecution(
|
|
| 1126 |
image_backend: metadata.imageBackend,
|
| 1127 |
stream_mode: metadata.streamMode,
|
| 1128 |
streaming_strategy: metadata.streamingStrategy,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1129 |
...(metadata.selectedCredential?.channelId
|
| 1130 |
? { selected_channel_id: metadata.selectedCredential.channelId }
|
| 1131 |
: {}),
|
|
|
|
| 7 |
validateAgentGenerateRequest
|
| 8 |
} from './agent-api-contracts';
|
| 9 |
import { AGENT_ENDPOINTS } from './agent-api-paths.mjs';
|
| 10 |
+
import {
|
| 11 |
+
buildAgentChannelRequestModeDecision,
|
| 12 |
+
createAgentChannelRequestModePlan,
|
| 13 |
+
selectAgentChannelCredential,
|
| 14 |
+
type AgentChannelRequestModePlan
|
| 15 |
+
} from './agent-channel-request-mode';
|
| 16 |
import { assertArtifactFilepathAllowed, deleteArtifactFileIfAllowed } from './agent-file-utils';
|
| 17 |
import {
|
| 18 |
artifactRecordToResponseItem,
|
|
|
|
| 26 |
type BeginAgentRequestResult,
|
| 27 |
type AgentStateStore
|
| 28 |
} from './agent-state-store';
|
| 29 |
+
import {
|
| 30 |
+
AgentApiError,
|
| 31 |
+
normalizeAgentError,
|
| 32 |
+
storedAgentErrorResponse,
|
| 33 |
+
type AgentErrorBody
|
| 34 |
+
} from './api-error-response';
|
| 35 |
import type { AgentErrorDiagnostics } from './api-error-response';
|
| 36 |
import { appLogger } from './app-logger';
|
| 37 |
import type { ChannelCapacityLease } from './channel-capacity-queue';
|
| 38 |
+
import {
|
| 39 |
+
isStreamingChannelRequestMode,
|
| 40 |
+
type ChannelRequestMode,
|
| 41 |
+
type ChannelRequestModeDecision
|
| 42 |
+
} from './channel-request-mode';
|
| 43 |
import {
|
| 44 |
type ChannelCredential,
|
| 45 |
type ChannelFailureReport,
|
|
|
|
| 69 |
MissingOpenAiImageDataError,
|
| 70 |
persistOpenAiImages as persistSharedOpenAiImages
|
| 71 |
} from './image-service';
|
| 72 |
+
import { collectOpenAiImagesFromStream } from './image-stream-collector';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
import {
|
| 74 |
readImageUpstreamProfile,
|
| 75 |
mergeUpstreamHeadersWithFixed,
|
|
|
|
| 78 |
type PartialImagesCount,
|
| 79 |
type UpstreamRequestHeaders
|
| 80 |
} from './image-upstream-profile';
|
| 81 |
+
import {
|
| 82 |
+
parseImageStreamModeValue,
|
| 83 |
+
parseImageStreamingStrategyValue,
|
| 84 |
+
resolveImageStreamEnabled,
|
| 85 |
+
type ImageStreamMode,
|
| 86 |
+
type ImageStreamingStrategy
|
| 87 |
+
} from './image-upstream-strategy';
|
| 88 |
import { createImagesApiGenerateStream } from './images-api-stream';
|
| 89 |
+
import { buildOpenAIImageRequestOptions, createOpenAIImageClientOptions } from './openai-image-transport';
|
| 90 |
import {
|
| 91 |
createResponsesImageStream,
|
| 92 |
generateImageWithResponsesBackend,
|
| 93 |
type ResponsesImageGenerateInput
|
| 94 |
} from './responses-image-backend';
|
|
|
|
| 95 |
import { getServerChannelState } from './server-channel-router';
|
| 96 |
+
import { readBooleanEnv } from './server-runtime';
|
| 97 |
import type { StreamingAvailabilityKey } from './streaming-availability';
|
|
|
|
| 98 |
import crypto from 'crypto';
|
| 99 |
import fs from 'fs/promises';
|
|
|
|
| 100 |
import { NextResponse } from 'next/server';
|
| 101 |
+
import OpenAI from 'openai';
|
| 102 |
|
| 103 |
export type AgentRequestExecutionResult = {
|
| 104 |
response: AgentImageResponse;
|
|
|
|
| 109 |
type CredentialContext = {
|
| 110 |
openai: OpenAI;
|
| 111 |
selectedCredential?: ChannelCredential;
|
| 112 |
+
channelRequestMode?: ChannelRequestMode;
|
| 113 |
+
channelRequestModeFallbackApplied: boolean;
|
| 114 |
+
channelRequestModeDecision: ChannelRequestModeDecision;
|
| 115 |
baseUrl?: string;
|
| 116 |
apiKey: string;
|
| 117 |
upstreamProfile: ImageUpstreamProfile;
|
|
|
|
| 142 |
streamingStrategy: ImageStreamingStrategy;
|
| 143 |
partialImages: PartialImagesCount;
|
| 144 |
selectedCredential?: ChannelCredential;
|
| 145 |
+
channelRequestMode?: ChannelRequestMode;
|
| 146 |
};
|
| 147 |
|
| 148 |
type AgentEditStreamRequest = {
|
|
|
|
| 161 |
imageBackend: AgentImageResponseExecution['image_backend'];
|
| 162 |
streamMode: ImageStreamMode;
|
| 163 |
streamingStrategy: ImageStreamingStrategy;
|
| 164 |
+
channelRequestMode?: ChannelRequestMode;
|
| 165 |
+
channelRequestModeFallbackApplied: boolean;
|
| 166 |
+
channelRequestModeDecision: ChannelRequestModeDecision;
|
| 167 |
selectedCredential?: ChannelCredential;
|
| 168 |
};
|
| 169 |
|
|
|
|
| 265 |
}
|
| 266 |
|
| 267 |
export function prepareAgentGenerate(request: AgentGenerateRequest, headers: Headers): AgentGeneratePreparation {
|
| 268 |
+
const credentialContext = createOpenAiClient(headers, resolveAgentGenerateChannelRequestModePlan(request));
|
| 269 |
validateAgentGenerateAgainstUpstreamProfile(request, credentialContext.upstreamProfile);
|
| 270 |
return { credentialContext };
|
| 271 |
}
|
| 272 |
|
| 273 |
+
function resolveAgentGenerateChannelRequestModePlan(request: AgentGenerateRequest): AgentChannelRequestModePlan {
|
| 274 |
+
return createAgentChannelRequestModePlan({
|
| 275 |
+
imageBackend: request.image_backend,
|
| 276 |
+
streamMode: request.stream_mode,
|
| 277 |
+
streamingStrategy: request.streaming_strategy
|
| 278 |
+
});
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function resolveAgentEditChannelRequestModePlan(formData: FormData): AgentChannelRequestModePlan {
|
| 282 |
+
return createAgentChannelRequestModePlan({
|
| 283 |
+
imageBackend: 'images-api',
|
| 284 |
+
streamMode: readAgentEditStreamMode(formData),
|
| 285 |
+
streamingStrategy: readAgentEditStreamingStrategy(formData)
|
| 286 |
+
});
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
export async function prepareAgentEdit(formData: FormData, headers: Headers): Promise<AgentEditPreparation> {
|
| 290 |
const prompt = readRequiredText(formData, 'prompt');
|
| 291 |
const model = readModel(formData);
|
| 292 |
assertImageFilesPresent(formData);
|
| 293 |
+
const credentialContext = createOpenAiClient(headers, resolveAgentEditChannelRequestModePlan(formData));
|
| 294 |
const n = readCount(
|
| 295 |
formData,
|
| 296 |
'n',
|
|
|
|
| 314 |
return { credentialContext, prompt, model, n, size, quality, responseMode, streamRequest, imageFiles, maskFile };
|
| 315 |
}
|
| 316 |
|
| 317 |
+
function buildOpenAiRequestOptions(context: CredentialContext, abortSignal?: AbortSignal): OpenAI.RequestOptions {
|
|
|
|
|
|
|
|
|
|
| 318 |
return buildOpenAIImageRequestOptions({
|
| 319 |
abortSignal,
|
| 320 |
headers: mergeUpstreamHeadersWithFixed(context.upstreamHeaders, {})
|
|
|
|
| 386 |
transport?: AgentExecutionTransportContext;
|
| 387 |
abortSignal?: AbortSignal;
|
| 388 |
}): Promise<AgentRequestExecutionResult> {
|
| 389 |
+
const credentialContext =
|
| 390 |
+
options.preparation?.credentialContext ??
|
| 391 |
+
prepareAgentGenerate(options.request, options.headers).credentialContext;
|
| 392 |
const startedAtMs = Date.now();
|
| 393 |
const startedAt = isoDate(new Date(startedAtMs));
|
| 394 |
let channelLease: ChannelCapacityLease | undefined;
|
| 395 |
try {
|
| 396 |
channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
|
| 397 |
+
const result = await executeAgentGenerateUpstream(options.request, credentialContext, options.abortSignal);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
channelLease?.release();
|
| 399 |
channelLease = undefined;
|
| 400 |
return await persistOpenAiImages({
|
|
|
|
| 422 |
imageBackend: options.request.image_backend,
|
| 423 |
streamMode: options.request.stream_mode,
|
| 424 |
streamingStrategy: options.request.streaming_strategy,
|
| 425 |
+
channelRequestMode: credentialContext.channelRequestMode,
|
| 426 |
+
channelRequestModeFallbackApplied: credentialContext.channelRequestModeFallbackApplied,
|
| 427 |
+
channelRequestModeDecision: credentialContext.channelRequestModeDecision,
|
| 428 |
selectedCredential: credentialContext.selectedCredential
|
| 429 |
},
|
| 430 |
abortSignal: options.abortSignal
|
| 431 |
});
|
| 432 |
} catch (error) {
|
| 433 |
+
const failureReport = reportServerCredentialFailure(
|
| 434 |
+
credentialContext.selectedCredential,
|
| 435 |
+
error,
|
| 436 |
+
credentialContext.channelRequestMode
|
| 437 |
+
);
|
| 438 |
throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs, failureReport));
|
| 439 |
} finally {
|
| 440 |
channelLease?.release();
|
|
|
|
| 453 |
streamMode: request.stream_mode,
|
| 454 |
streamingStrategy: request.streaming_strategy,
|
| 455 |
partialImages: request.partial_images,
|
| 456 |
+
selectedCredential: credentialContext.selectedCredential,
|
| 457 |
+
channelRequestMode: credentialContext.channelRequestMode
|
| 458 |
};
|
| 459 |
if (request.image_backend === 'responses-image-generation') {
|
| 460 |
return executeAgentResponsesGenerate(request, credentialContext, abortSignal);
|
|
|
|
| 517 |
422
|
| 518 |
);
|
| 519 |
}
|
| 520 |
+
if (
|
| 521 |
+
request.partial_images < upstreamProfile.partialImages.min ||
|
| 522 |
+
request.partial_images > upstreamProfile.partialImages.max
|
| 523 |
+
) {
|
| 524 |
throw new RequestValidationError(
|
| 525 |
`partial_images 必须在 ${upstreamProfile.partialImages.min} 到 ${upstreamProfile.partialImages.max} 之间。`,
|
| 526 |
422
|
|
|
|
| 573 |
streamMode: request.stream_mode,
|
| 574 |
streamingStrategy: request.streaming_strategy,
|
| 575 |
partialImages: request.partial_images,
|
| 576 |
+
selectedCredential: credentialContext.selectedCredential,
|
| 577 |
+
channelRequestMode: credentialContext.channelRequestMode
|
| 578 |
};
|
| 579 |
if (!shouldUseAgentUpstreamStream(streamOptions)) {
|
| 580 |
return generateImageWithResponsesBackend(input);
|
|
|
|
| 607 |
const key = createAgentStreamingAvailabilityKey(input);
|
| 608 |
const availability = getServerChannelState().streamingAvailability;
|
| 609 |
if (input.streamMode === 'non_stream') return false;
|
| 610 |
+
if (input.channelRequestMode && !isStreamingChannelRequestMode(input.channelRequestMode)) return false;
|
| 611 |
if (input.streamMode === 'auto' && availability.isUnavailable(key)) return false;
|
| 612 |
return resolveImageStreamEnabled({
|
| 613 |
imageBackend: input.imageBackend,
|
|
|
|
| 690 |
const startedAt = isoDate(new Date(startedAtMs));
|
| 691 |
let channelLease: ChannelCapacityLease | undefined;
|
| 692 |
try {
|
| 693 |
+
const preparation = options.preparation ?? (await prepareAgentEdit(options.formData, options.headers));
|
| 694 |
credentialContext = preparation.credentialContext;
|
| 695 |
const editParams: OpenAI.Images.ImageEditParamsNonStreaming = {
|
| 696 |
model: preparation.model,
|
|
|
|
| 707 |
streamMode: preparation.streamRequest.streamMode,
|
| 708 |
streamingStrategy: preparation.streamRequest.streamingStrategy,
|
| 709 |
partialImages: preparation.streamRequest.partialImages,
|
| 710 |
+
selectedCredential: credentialContext.selectedCredential,
|
| 711 |
+
channelRequestMode: credentialContext.channelRequestMode
|
| 712 |
};
|
| 713 |
channelLease = await acquireAgentChannelCapacity(credentialContext, options.abortSignal);
|
| 714 |
const result = shouldUseAgentUpstreamStream(streamOptions)
|
|
|
|
| 750 |
imageBackend: 'images-api',
|
| 751 |
streamMode: preparation.streamRequest.streamMode,
|
| 752 |
streamingStrategy: preparation.streamRequest.streamingStrategy,
|
| 753 |
+
channelRequestMode: credentialContext.channelRequestMode,
|
| 754 |
+
channelRequestModeFallbackApplied: credentialContext.channelRequestModeFallbackApplied,
|
| 755 |
+
channelRequestModeDecision: credentialContext.channelRequestModeDecision,
|
| 756 |
selectedCredential: credentialContext.selectedCredential
|
| 757 |
},
|
| 758 |
abortSignal: options.abortSignal
|
| 759 |
});
|
| 760 |
} catch (error) {
|
| 761 |
+
const failureReport = reportServerCredentialFailure(
|
| 762 |
+
credentialContext?.selectedCredential,
|
| 763 |
+
error,
|
| 764 |
+
credentialContext?.channelRequestMode
|
| 765 |
+
);
|
| 766 |
throw normalizeAgentError(error, buildAgentExecutionDiagnostics(credentialContext, startedAtMs, failureReport));
|
| 767 |
} finally {
|
| 768 |
channelLease?.release();
|
|
|
|
| 950 |
};
|
| 951 |
}
|
| 952 |
|
| 953 |
+
function createOpenAiClient(headers: Headers, requestModePlan: AgentChannelRequestModePlan): CredentialContext {
|
| 954 |
const serverChannelRouter = getServerChannelState().router;
|
| 955 |
+
const selection = selectAgentChannelCredential({
|
| 956 |
+
router: serverChannelRouter,
|
| 957 |
+
headers,
|
| 958 |
+
requestModePlan
|
| 959 |
+
});
|
| 960 |
+
const selectedCredential = selection.selectedCredential;
|
| 961 |
const {
|
| 962 |
apiKey,
|
| 963 |
baseUrl,
|
|
|
|
| 972 |
validateApiBaseUrl(baseUrl || '', {
|
| 973 |
allowedPlainHttpBaseUrls: readPlainHttpApiBaseUrlAllowlist(process.env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS)
|
| 974 |
});
|
| 975 |
+
const channelRequestModeDecision = buildAgentChannelRequestModeDecision({
|
| 976 |
+
requestModePlan,
|
| 977 |
+
selection,
|
| 978 |
+
selectedCredential: effectiveSelectedCredential,
|
| 979 |
+
upstreamHost: baseUrl ? readUrlHost(baseUrl) : undefined
|
| 980 |
+
});
|
| 981 |
if (!apiKey) {
|
| 982 |
throw new AgentApiError({
|
| 983 |
code: 'configuration_error',
|
| 984 |
message: '未配置服务端 API Key。请设置 OPENAI_API_KEY 或 OPENAI_CHANNEL_N_API_KEYS。',
|
| 985 |
status: 500,
|
| 986 |
+
retryable: false,
|
| 987 |
+
diagnostics: {
|
| 988 |
+
route_decision: channelRequestModeDecision
|
| 989 |
+
}
|
| 990 |
});
|
| 991 |
}
|
| 992 |
return {
|
|
|
|
| 998 |
})
|
| 999 |
),
|
| 1000 |
selectedCredential: effectiveSelectedCredential,
|
| 1001 |
+
channelRequestMode: selection.requestMode,
|
| 1002 |
+
channelRequestModeFallbackApplied: selection.fallbackApplied,
|
| 1003 |
+
channelRequestModeDecision,
|
| 1004 |
baseUrl,
|
| 1005 |
apiKey,
|
| 1006 |
upstreamProfile:
|
|
|
|
| 1022 |
const upstreamHost = context?.baseUrl ? readUrlHost(context.baseUrl) : undefined;
|
| 1023 |
return {
|
| 1024 |
elapsed_ms: Date.now() - startedAtMs,
|
| 1025 |
+
...(context?.channelRequestMode ? { channel_request_mode: context.channelRequestMode } : {}),
|
| 1026 |
+
...(context ? { channel_request_mode_fallback_applied: context.channelRequestModeFallbackApplied } : {}),
|
| 1027 |
+
...(context?.channelRequestModeDecision ? { route_decision: context.channelRequestModeDecision } : {}),
|
| 1028 |
...(context?.selectedCredential?.channelId
|
| 1029 |
? { selected_channel_id: context.selectedCredential.channelId }
|
| 1030 |
: {}),
|
| 1031 |
...(upstreamHost ? { upstream_host: upstreamHost } : {}),
|
| 1032 |
+
...(failureReport?.cooldownApplied
|
| 1033 |
+
? {
|
| 1034 |
+
retry_after_ms: failureReport.retryAfterMs,
|
| 1035 |
+
cooldown_until: isoDate(new Date(failureReport.cooldownUntil)),
|
| 1036 |
+
cooldown_target: {
|
| 1037 |
+
channel_id: failureReport.target.channelId,
|
| 1038 |
+
...(failureReport.target.credentialId
|
| 1039 |
+
? { credential_id: failureReport.target.credentialId }
|
| 1040 |
+
: {}),
|
| 1041 |
+
...(failureReport.target.requestMode ? { request_mode: failureReport.target.requestMode } : {})
|
| 1042 |
+
},
|
| 1043 |
+
channel_cooldown_scope: failureReport.scope
|
| 1044 |
+
}
|
| 1045 |
+
: {})
|
| 1046 |
};
|
| 1047 |
}
|
| 1048 |
|
|
|
|
| 1056 |
|
| 1057 |
function reportServerCredentialFailure(
|
| 1058 |
credential: ChannelCredential | undefined,
|
| 1059 |
+
error: unknown,
|
| 1060 |
+
requestMode?: ChannelRequestMode
|
| 1061 |
): ChannelFailureReport | undefined {
|
| 1062 |
const serverChannelRouter = getServerChannelState().router;
|
| 1063 |
if (!credential || !serverChannelRouter) return undefined;
|
| 1064 |
if (isChannelFailure(error)) {
|
| 1065 |
+
const reason = {
|
| 1066 |
+
...describeChannelFailure(error, 'channel'),
|
| 1067 |
+
...(requestMode ? { requestMode } : {})
|
| 1068 |
+
};
|
| 1069 |
+
const report = serverChannelRouter.reportFailure(credential, { scope: 'channel', requestMode, reason });
|
| 1070 |
appLogger.warn(
|
| 1071 |
report.cooldownApplied
|
| 1072 |
? `Temporarily cooling down API channel: ${credential.channelId}`
|
|
|
|
| 1076 |
return report;
|
| 1077 |
}
|
| 1078 |
if (isCredentialFailure(error)) {
|
| 1079 |
+
const reason = {
|
| 1080 |
+
...describeChannelFailure(error, 'credential'),
|
| 1081 |
+
...(requestMode ? { requestMode } : {})
|
| 1082 |
+
};
|
| 1083 |
+
const report = serverChannelRouter.reportFailure(credential, { requestMode, reason });
|
| 1084 |
appLogger.warn(
|
| 1085 |
report.cooldownApplied
|
| 1086 |
? `Temporarily cooling down API channel credential: ${credential.channelId}/${credential.id}`
|
|
|
|
| 1213 |
image_backend: metadata.imageBackend,
|
| 1214 |
stream_mode: metadata.streamMode,
|
| 1215 |
streaming_strategy: metadata.streamingStrategy,
|
| 1216 |
+
...(metadata.channelRequestMode ? { channel_request_mode: metadata.channelRequestMode } : {}),
|
| 1217 |
+
channel_request_mode_fallback_applied: metadata.channelRequestModeFallbackApplied,
|
| 1218 |
+
route_decision: {
|
| 1219 |
+
...metadata.channelRequestModeDecision,
|
| 1220 |
+
...(upstreamHost ? { upstream_host: upstreamHost } : {})
|
| 1221 |
+
},
|
| 1222 |
...(metadata.selectedCredential?.channelId
|
| 1223 |
? { selected_channel_id: metadata.selectedCredential.channelId }
|
| 1224 |
: {}),
|
src/lib/agent-openapi.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
| 19 |
readAgentPublicBaseUrl
|
| 20 |
} from './agent-api-contracts';
|
| 21 |
import { AGENT_ENDPOINTS } from './agent-api-paths.mjs';
|
|
|
|
| 22 |
import { MAX_PROMPT_LENGTH } from './image-request-utils';
|
| 23 |
|
| 24 |
type AgentOpenApiSecurityRequirement = { BearerAuth: [] } | { AppPasswordHash: [] };
|
|
@@ -429,6 +430,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 429 |
'image_transport',
|
| 430 |
'upstream_profile',
|
| 431 |
'upstream_request_headers',
|
|
|
|
| 432 |
'defaults',
|
| 433 |
'limits',
|
| 434 |
'model_limits',
|
|
@@ -468,9 +470,13 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 468 |
type: 'array',
|
| 469 |
items: {
|
| 470 |
type: 'object',
|
| 471 |
-
required: ['id', 'request_headers'],
|
| 472 |
properties: {
|
| 473 |
id: { type: 'string' },
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
request_headers: { $ref: '#/components/schemas/UpstreamRequestHeaderSummary' }
|
| 475 |
},
|
| 476 |
additionalProperties: false
|
|
@@ -479,6 +485,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 479 |
},
|
| 480 |
additionalProperties: false
|
| 481 |
},
|
|
|
|
| 482 |
upstream_profile: {
|
| 483 |
type: 'object',
|
| 484 |
required: [
|
|
@@ -666,6 +673,7 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 666 |
'image_backends',
|
| 667 |
'enabled_image_backends',
|
| 668 |
'image_backend_requirements',
|
|
|
|
| 669 |
'streaming_strategies',
|
| 670 |
'stream_modes'
|
| 671 |
],
|
|
@@ -695,6 +703,10 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 695 |
type: 'object',
|
| 696 |
additionalProperties: { $ref: '#/components/schemas/ImageBackendRequirement' }
|
| 697 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
| 698 |
streaming_strategies: {
|
| 699 |
type: 'array',
|
| 700 |
items: { type: 'string', enum: AGENT_STREAMING_STRATEGIES }
|
|
@@ -1404,6 +1416,21 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1404 |
},
|
| 1405 |
additionalProperties: false
|
| 1406 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1407 |
AgentImageResponseExecution: {
|
| 1408 |
type: 'object',
|
| 1409 |
required: [
|
|
@@ -1414,6 +1441,8 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1414 |
'image_backend',
|
| 1415 |
'stream_mode',
|
| 1416 |
'streaming_strategy',
|
|
|
|
|
|
|
| 1417 |
'request_headers'
|
| 1418 |
],
|
| 1419 |
properties: {
|
|
@@ -1424,6 +1453,9 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1424 |
image_backend: { type: 'string', enum: AGENT_IMAGE_BACKENDS },
|
| 1425 |
stream_mode: { type: 'string', enum: AGENT_STREAM_MODES },
|
| 1426 |
streaming_strategy: { type: 'string', enum: AGENT_STREAMING_STRATEGIES },
|
|
|
|
|
|
|
|
|
|
| 1427 |
selected_channel_id: { type: 'string' },
|
| 1428 |
upstream_host: { type: 'string' },
|
| 1429 |
request_headers: { $ref: '#/components/schemas/UpstreamRequestHeaderSummary' }
|
|
@@ -1784,6 +1816,9 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1784 |
type: 'object',
|
| 1785 |
properties: {
|
| 1786 |
elapsed_ms: { type: 'integer', minimum: 0 },
|
|
|
|
|
|
|
|
|
|
| 1787 |
selected_channel_id: { type: 'string' },
|
| 1788 |
upstream_host: { type: 'string' },
|
| 1789 |
upstream_status: { type: 'integer' },
|
|
@@ -1812,7 +1847,8 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1812 |
required: ['channel_id'],
|
| 1813 |
properties: {
|
| 1814 |
channel_id: { type: 'string' },
|
| 1815 |
-
credential_id: { type: 'string' }
|
|
|
|
| 1816 |
},
|
| 1817 |
additionalProperties: false
|
| 1818 |
},
|
|
@@ -1846,6 +1882,34 @@ export function buildAgentOpenApiDocument(env: Record<string, string | undefined
|
|
| 1846 |
},
|
| 1847 |
additionalProperties: false
|
| 1848 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1849 |
ImageTransportCapabilities: {
|
| 1850 |
type: 'object',
|
| 1851 |
required: [
|
|
|
|
| 19 |
readAgentPublicBaseUrl
|
| 20 |
} from './agent-api-contracts';
|
| 21 |
import { AGENT_ENDPOINTS } from './agent-api-paths.mjs';
|
| 22 |
+
import { CHANNEL_REQUEST_MODES } from './channel-request-mode';
|
| 23 |
import { MAX_PROMPT_LENGTH } from './image-request-utils';
|
| 24 |
|
| 25 |
type AgentOpenApiSecurityRequirement = { BearerAuth: [] } | { AppPasswordHash: [] };
|
|
|
|
| 430 |
'image_transport',
|
| 431 |
'upstream_profile',
|
| 432 |
'upstream_request_headers',
|
| 433 |
+
'request_mode_controls',
|
| 434 |
'defaults',
|
| 435 |
'limits',
|
| 436 |
'model_limits',
|
|
|
|
| 470 |
type: 'array',
|
| 471 |
items: {
|
| 472 |
type: 'object',
|
| 473 |
+
required: ['id', 'request_modes', 'request_headers'],
|
| 474 |
properties: {
|
| 475 |
id: { type: 'string' },
|
| 476 |
+
request_modes: {
|
| 477 |
+
type: 'array',
|
| 478 |
+
items: { type: 'string', enum: CHANNEL_REQUEST_MODES }
|
| 479 |
+
},
|
| 480 |
request_headers: { $ref: '#/components/schemas/UpstreamRequestHeaderSummary' }
|
| 481 |
},
|
| 482 |
additionalProperties: false
|
|
|
|
| 485 |
},
|
| 486 |
additionalProperties: false
|
| 487 |
},
|
| 488 |
+
request_mode_controls: { $ref: '#/components/schemas/AgentRequestModeControls' },
|
| 489 |
upstream_profile: {
|
| 490 |
type: 'object',
|
| 491 |
required: [
|
|
|
|
| 673 |
'image_backends',
|
| 674 |
'enabled_image_backends',
|
| 675 |
'image_backend_requirements',
|
| 676 |
+
'request_modes',
|
| 677 |
'streaming_strategies',
|
| 678 |
'stream_modes'
|
| 679 |
],
|
|
|
|
| 703 |
type: 'object',
|
| 704 |
additionalProperties: { $ref: '#/components/schemas/ImageBackendRequirement' }
|
| 705 |
},
|
| 706 |
+
request_modes: {
|
| 707 |
+
type: 'array',
|
| 708 |
+
items: { type: 'string', enum: CHANNEL_REQUEST_MODES }
|
| 709 |
+
},
|
| 710 |
streaming_strategies: {
|
| 711 |
type: 'array',
|
| 712 |
items: { type: 'string', enum: AGENT_STREAMING_STRATEGIES }
|
|
|
|
| 1416 |
},
|
| 1417 |
additionalProperties: false
|
| 1418 |
},
|
| 1419 |
+
ChannelRequestModeDecision: {
|
| 1420 |
+
type: 'object',
|
| 1421 |
+
required: ['requested_backend', 'fallback_applied'],
|
| 1422 |
+
properties: {
|
| 1423 |
+
requested_backend: { type: 'string', enum: AGENT_IMAGE_BACKENDS },
|
| 1424 |
+
preferred_channel_request_mode: { type: 'string', enum: CHANNEL_REQUEST_MODES },
|
| 1425 |
+
fallback_channel_request_mode: { type: 'string', enum: CHANNEL_REQUEST_MODES },
|
| 1426 |
+
selected_channel_request_mode: { type: 'string', enum: CHANNEL_REQUEST_MODES },
|
| 1427 |
+
fallback_applied: { type: 'boolean' },
|
| 1428 |
+
selected_channel_id: { type: 'string' },
|
| 1429 |
+
upstream_host: { type: 'string' },
|
| 1430 |
+
no_channel_reason: { type: 'string' }
|
| 1431 |
+
},
|
| 1432 |
+
additionalProperties: false
|
| 1433 |
+
},
|
| 1434 |
AgentImageResponseExecution: {
|
| 1435 |
type: 'object',
|
| 1436 |
required: [
|
|
|
|
| 1441 |
'image_backend',
|
| 1442 |
'stream_mode',
|
| 1443 |
'streaming_strategy',
|
| 1444 |
+
'channel_request_mode_fallback_applied',
|
| 1445 |
+
'route_decision',
|
| 1446 |
'request_headers'
|
| 1447 |
],
|
| 1448 |
properties: {
|
|
|
|
| 1453 |
image_backend: { type: 'string', enum: AGENT_IMAGE_BACKENDS },
|
| 1454 |
stream_mode: { type: 'string', enum: AGENT_STREAM_MODES },
|
| 1455 |
streaming_strategy: { type: 'string', enum: AGENT_STREAMING_STRATEGIES },
|
| 1456 |
+
channel_request_mode: { type: 'string', enum: CHANNEL_REQUEST_MODES },
|
| 1457 |
+
channel_request_mode_fallback_applied: { type: 'boolean' },
|
| 1458 |
+
route_decision: { $ref: '#/components/schemas/ChannelRequestModeDecision' },
|
| 1459 |
selected_channel_id: { type: 'string' },
|
| 1460 |
upstream_host: { type: 'string' },
|
| 1461 |
request_headers: { $ref: '#/components/schemas/UpstreamRequestHeaderSummary' }
|
|
|
|
| 1816 |
type: 'object',
|
| 1817 |
properties: {
|
| 1818 |
elapsed_ms: { type: 'integer', minimum: 0 },
|
| 1819 |
+
channel_request_mode: { type: 'string', enum: CHANNEL_REQUEST_MODES },
|
| 1820 |
+
channel_request_mode_fallback_applied: { type: 'boolean' },
|
| 1821 |
+
route_decision: { $ref: '#/components/schemas/ChannelRequestModeDecision' },
|
| 1822 |
selected_channel_id: { type: 'string' },
|
| 1823 |
upstream_host: { type: 'string' },
|
| 1824 |
upstream_status: { type: 'integer' },
|
|
|
|
| 1847 |
required: ['channel_id'],
|
| 1848 |
properties: {
|
| 1849 |
channel_id: { type: 'string' },
|
| 1850 |
+
credential_id: { type: 'string' },
|
| 1851 |
+
request_mode: { type: 'string', enum: CHANNEL_REQUEST_MODES }
|
| 1852 |
},
|
| 1853 |
additionalProperties: false
|
| 1854 |
},
|
|
|
|
| 1882 |
},
|
| 1883 |
additionalProperties: false
|
| 1884 |
},
|
| 1885 |
+
AgentRequestModeControls: {
|
| 1886 |
+
type: 'object',
|
| 1887 |
+
required: [
|
| 1888 |
+
'source',
|
| 1889 |
+
'global_env',
|
| 1890 |
+
'channel_env_pattern',
|
| 1891 |
+
'mutable_at_runtime',
|
| 1892 |
+
'agent_client_policy',
|
| 1893 |
+
'final_gate_command',
|
| 1894 |
+
'smoke_gate_commands'
|
| 1895 |
+
],
|
| 1896 |
+
properties: {
|
| 1897 |
+
source: { type: 'string', const: 'admin_env_whitelist' },
|
| 1898 |
+
global_env: { type: 'string' },
|
| 1899 |
+
channel_env_pattern: { type: 'string' },
|
| 1900 |
+
mutable_at_runtime: { type: 'boolean', const: false },
|
| 1901 |
+
agent_client_policy: { type: 'string', const: 'diagnostics_only' },
|
| 1902 |
+
final_gate_command: { type: 'string' },
|
| 1903 |
+
smoke_gate_commands: {
|
| 1904 |
+
type: 'object',
|
| 1905 |
+
additionalProperties: {
|
| 1906 |
+
type: 'array',
|
| 1907 |
+
items: { type: 'string' }
|
| 1908 |
+
}
|
| 1909 |
+
}
|
| 1910 |
+
},
|
| 1911 |
+
additionalProperties: false
|
| 1912 |
+
},
|
| 1913 |
ImageTransportCapabilities: {
|
| 1914 |
type: 'object',
|
| 1915 |
required: [
|
src/lib/api-error-response.test.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
import {
|
| 2 |
AgentApiError,
|
|
|
|
| 3 |
agentErrorResponse,
|
| 4 |
createAgentErrorBody,
|
| 5 |
normalizeAgentError,
|
|
@@ -96,6 +97,12 @@ describe('normalizeAgentError', () => {
|
|
| 96 |
});
|
| 97 |
|
| 98 |
it('adds sanitized upstream diagnostics without inventing an HTTP status', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
const error = normalizeAgentError(
|
| 100 |
Object.assign(new Error('Connection error.'), {
|
| 101 |
name: 'APIConnectionError',
|
|
@@ -110,9 +117,7 @@ describe('normalizeAgentError', () => {
|
|
| 110 |
upstream_host: 'api.example.test',
|
| 111 |
retry_after_ms: 15000,
|
| 112 |
cooldown_until: '2026-06-11T00:00:15.000Z',
|
| 113 |
-
cooldown_target:
|
| 114 |
-
channel_id: 'channel-a'
|
| 115 |
-
}
|
| 116 |
}
|
| 117 |
);
|
| 118 |
const body = createAgentErrorBody(error, 'request-2');
|
|
@@ -124,11 +129,54 @@ describe('normalizeAgentError', () => {
|
|
| 124 |
assert.equal(body.error.diagnostics?.transport_error, true);
|
| 125 |
assert.equal(body.error.diagnostics?.retry_after_ms, 15000);
|
| 126 |
assert.equal(body.error.diagnostics?.cooldown_until, '2026-06-11T00:00:15.000Z');
|
| 127 |
-
assert.deepEqual(body.error.diagnostics?.cooldown_target, {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
assert.deepEqual(body.error.diagnostics?.response_headers, { 'cf-ray': 'abc-SJC' });
|
| 129 |
assert.equal(JSON.stringify(body).includes('secret'), false);
|
| 130 |
});
|
| 131 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
it('filters caller-provided diagnostic response headers through the allowlist', () => {
|
| 133 |
const error = normalizeAgentError(new Error('diagnostics'), {
|
| 134 |
response_headers: {
|
|
|
|
| 1 |
import {
|
| 2 |
AgentApiError,
|
| 3 |
+
type AgentErrorDiagnostics,
|
| 4 |
agentErrorResponse,
|
| 5 |
createAgentErrorBody,
|
| 6 |
normalizeAgentError,
|
|
|
|
| 97 |
});
|
| 98 |
|
| 99 |
it('adds sanitized upstream diagnostics without inventing an HTTP status', () => {
|
| 100 |
+
const unsafeCooldownTarget = {
|
| 101 |
+
channel_id: ' channel-a ',
|
| 102 |
+
credential_id: ' credential-a ',
|
| 103 |
+
request_mode: 'images-non-stream',
|
| 104 |
+
api_key: 'secret'
|
| 105 |
+
} as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']>;
|
| 106 |
const error = normalizeAgentError(
|
| 107 |
Object.assign(new Error('Connection error.'), {
|
| 108 |
name: 'APIConnectionError',
|
|
|
|
| 117 |
upstream_host: 'api.example.test',
|
| 118 |
retry_after_ms: 15000,
|
| 119 |
cooldown_until: '2026-06-11T00:00:15.000Z',
|
| 120 |
+
cooldown_target: unsafeCooldownTarget
|
|
|
|
|
|
|
| 121 |
}
|
| 122 |
);
|
| 123 |
const body = createAgentErrorBody(error, 'request-2');
|
|
|
|
| 129 |
assert.equal(body.error.diagnostics?.transport_error, true);
|
| 130 |
assert.equal(body.error.diagnostics?.retry_after_ms, 15000);
|
| 131 |
assert.equal(body.error.diagnostics?.cooldown_until, '2026-06-11T00:00:15.000Z');
|
| 132 |
+
assert.deepEqual(body.error.diagnostics?.cooldown_target, {
|
| 133 |
+
channel_id: 'channel-a',
|
| 134 |
+
credential_id: 'credential-a',
|
| 135 |
+
request_mode: 'images-non-stream'
|
| 136 |
+
});
|
| 137 |
assert.deepEqual(body.error.diagnostics?.response_headers, { 'cf-ray': 'abc-SJC' });
|
| 138 |
assert.equal(JSON.stringify(body).includes('secret'), false);
|
| 139 |
});
|
| 140 |
|
| 141 |
+
it('drops invalid cooldown target diagnostics', () => {
|
| 142 |
+
const error = normalizeAgentError(new Error('diagnostics'), {
|
| 143 |
+
retry_after_ms: 15000,
|
| 144 |
+
cooldown_target: {
|
| 145 |
+
channel_id: 'channel-a',
|
| 146 |
+
request_mode: 'invalid-mode'
|
| 147 |
+
} as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']>
|
| 148 |
+
});
|
| 149 |
+
const body = createAgentErrorBody(error, 'request-invalid-cooldown-target');
|
| 150 |
+
|
| 151 |
+
assert.equal(body.error.diagnostics?.retry_after_ms, 15000);
|
| 152 |
+
assert.deepEqual(body.error.diagnostics?.cooldown_target, { channel_id: 'channel-a' });
|
| 153 |
+
});
|
| 154 |
+
|
| 155 |
+
it('drops cooldown targets without a valid channel id', () => {
|
| 156 |
+
const error = normalizeAgentError(new Error('diagnostics'), {
|
| 157 |
+
retry_after_ms: 15000,
|
| 158 |
+
cooldown_target: {
|
| 159 |
+
channel_id: ' ',
|
| 160 |
+
request_mode: 'images-non-stream'
|
| 161 |
+
} as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']>
|
| 162 |
+
});
|
| 163 |
+
const body = createAgentErrorBody(error, 'request-blank-cooldown-target');
|
| 164 |
+
|
| 165 |
+
assert.equal(body.error.diagnostics?.retry_after_ms, 15000);
|
| 166 |
+
assert.equal(body.error.diagnostics?.cooldown_target, undefined);
|
| 167 |
+
});
|
| 168 |
+
|
| 169 |
+
it('drops cooldown target diagnostics that are not objects', () => {
|
| 170 |
+
const error = normalizeAgentError(new Error('diagnostics'), {
|
| 171 |
+
retry_after_ms: 15000,
|
| 172 |
+
cooldown_target: 'channel-a' as unknown as NonNullable<AgentErrorDiagnostics['cooldown_target']>
|
| 173 |
+
});
|
| 174 |
+
const body = createAgentErrorBody(error, 'request-string-cooldown-target');
|
| 175 |
+
|
| 176 |
+
assert.equal(body.error.diagnostics?.retry_after_ms, 15000);
|
| 177 |
+
assert.equal(body.error.diagnostics?.cooldown_target, undefined);
|
| 178 |
+
});
|
| 179 |
+
|
| 180 |
it('filters caller-provided diagnostic response headers through the allowlist', () => {
|
| 181 |
const error = normalizeAgentError(new Error('diagnostics'), {
|
| 182 |
response_headers: {
|
src/lib/api-error-response.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
| 1 |
import { RequestValidationError } from './image-request-utils';
|
| 2 |
import { isChannelFailure } from './channel-router';
|
| 3 |
import { ChannelCapacityQueueError } from './channel-capacity-queue';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { NextResponse } from 'next/server';
|
| 5 |
|
| 6 |
export type AgentErrorCode =
|
|
@@ -20,6 +25,9 @@ export type AgentErrorCode =
|
|
| 20 |
|
| 21 |
export type AgentErrorDiagnostics = {
|
| 22 |
elapsed_ms?: number;
|
|
|
|
|
|
|
|
|
|
| 23 |
selected_channel_id?: string;
|
| 24 |
upstream_host?: string;
|
| 25 |
upstream_status?: number;
|
|
@@ -33,6 +41,7 @@ export type AgentErrorDiagnostics = {
|
|
| 33 |
cooldown_target?: {
|
| 34 |
channel_id: string;
|
| 35 |
credential_id?: string;
|
|
|
|
| 36 |
};
|
| 37 |
channel_cooldown_scope?: 'credential' | 'channel';
|
| 38 |
response_headers?: Record<string, string>;
|
|
@@ -159,8 +168,14 @@ function cleanDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): Agent
|
|
| 159 |
diagnostics.retry_after_ms !== undefined
|
| 160 |
? normalizeNonNegativeInteger(diagnostics.retry_after_ms)
|
| 161 |
: undefined;
|
|
|
|
| 162 |
const cleaned: AgentErrorDiagnostics = {
|
| 163 |
...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
...(diagnostics.selected_channel_id ? { selected_channel_id: diagnostics.selected_channel_id } : {}),
|
| 165 |
...(diagnostics.upstream_host ? { upstream_host: diagnostics.upstream_host } : {}),
|
| 166 |
...(diagnostics.upstream_status !== undefined ? { upstream_status: diagnostics.upstream_status } : {}),
|
|
@@ -171,13 +186,36 @@ function cleanDiagnostics(diagnostics: AgentErrorDiagnostics | undefined): Agent
|
|
| 171 |
...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
|
| 172 |
...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}),
|
| 173 |
...(diagnostics.cooldown_until ? { cooldown_until: diagnostics.cooldown_until } : {}),
|
| 174 |
-
...(
|
| 175 |
...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}),
|
| 176 |
...(responseHeaders ? { response_headers: responseHeaders } : {})
|
| 177 |
};
|
| 178 |
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
| 179 |
}
|
| 180 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
function readNumberField(error: unknown, field: string): number | undefined {
|
| 182 |
if (typeof error !== 'object' || error === null || !(field in error)) return undefined;
|
| 183 |
const value = (error as Record<string, unknown>)[field];
|
|
|
|
| 1 |
import { RequestValidationError } from './image-request-utils';
|
| 2 |
import { isChannelFailure } from './channel-router';
|
| 3 |
import { ChannelCapacityQueueError } from './channel-capacity-queue';
|
| 4 |
+
import {
|
| 5 |
+
CHANNEL_REQUEST_MODES,
|
| 6 |
+
type ChannelRequestMode,
|
| 7 |
+
type ChannelRequestModeDecision
|
| 8 |
+
} from './channel-request-mode';
|
| 9 |
import { NextResponse } from 'next/server';
|
| 10 |
|
| 11 |
export type AgentErrorCode =
|
|
|
|
| 25 |
|
| 26 |
export type AgentErrorDiagnostics = {
|
| 27 |
elapsed_ms?: number;
|
| 28 |
+
channel_request_mode?: ChannelRequestMode;
|
| 29 |
+
channel_request_mode_fallback_applied?: boolean;
|
| 30 |
+
route_decision?: ChannelRequestModeDecision;
|
| 31 |
selected_channel_id?: string;
|
| 32 |
upstream_host?: string;
|
| 33 |
upstream_status?: number;
|
|
|
|
| 41 |
cooldown_target?: {
|
| 42 |
channel_id: string;
|
| 43 |
credential_id?: string;
|
| 44 |
+
request_mode?: ChannelRequestMode;
|
| 45 |
};
|
| 46 |
channel_cooldown_scope?: 'credential' | 'channel';
|
| 47 |
response_headers?: Record<string, string>;
|
|
|
|
| 168 |
diagnostics.retry_after_ms !== undefined
|
| 169 |
? normalizeNonNegativeInteger(diagnostics.retry_after_ms)
|
| 170 |
: undefined;
|
| 171 |
+
const cooldownTarget = cleanCooldownTarget(diagnostics.cooldown_target);
|
| 172 |
const cleaned: AgentErrorDiagnostics = {
|
| 173 |
...(diagnostics.elapsed_ms !== undefined ? { elapsed_ms: Math.max(0, Math.round(diagnostics.elapsed_ms)) } : {}),
|
| 174 |
+
...(diagnostics.channel_request_mode ? { channel_request_mode: diagnostics.channel_request_mode } : {}),
|
| 175 |
+
...(diagnostics.channel_request_mode_fallback_applied !== undefined
|
| 176 |
+
? { channel_request_mode_fallback_applied: diagnostics.channel_request_mode_fallback_applied }
|
| 177 |
+
: {}),
|
| 178 |
+
...(diagnostics.route_decision ? { route_decision: diagnostics.route_decision } : {}),
|
| 179 |
...(diagnostics.selected_channel_id ? { selected_channel_id: diagnostics.selected_channel_id } : {}),
|
| 180 |
...(diagnostics.upstream_host ? { upstream_host: diagnostics.upstream_host } : {}),
|
| 181 |
...(diagnostics.upstream_status !== undefined ? { upstream_status: diagnostics.upstream_status } : {}),
|
|
|
|
| 186 |
...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
|
| 187 |
...(retryAfterMs !== undefined ? { retry_after_ms: retryAfterMs } : {}),
|
| 188 |
...(diagnostics.cooldown_until ? { cooldown_until: diagnostics.cooldown_until } : {}),
|
| 189 |
+
...(cooldownTarget ? { cooldown_target: cooldownTarget } : {}),
|
| 190 |
...(diagnostics.channel_cooldown_scope ? { channel_cooldown_scope: diagnostics.channel_cooldown_scope } : {}),
|
| 191 |
...(responseHeaders ? { response_headers: responseHeaders } : {})
|
| 192 |
};
|
| 193 |
return Object.keys(cleaned).length > 0 ? cleaned : undefined;
|
| 194 |
}
|
| 195 |
|
| 196 |
+
function cleanCooldownTarget(target: unknown): AgentErrorDiagnostics['cooldown_target'] | undefined {
|
| 197 |
+
if (typeof target !== 'object' || target === null) return undefined;
|
| 198 |
+
const source = target as Record<string, unknown>;
|
| 199 |
+
const channelId = normalizeNonEmptyString(source.channel_id);
|
| 200 |
+
if (!channelId) return undefined;
|
| 201 |
+
const credentialId = normalizeNonEmptyString(source.credential_id);
|
| 202 |
+
const requestMode = normalizeDiagnosticRequestMode(source.request_mode);
|
| 203 |
+
return {
|
| 204 |
+
channel_id: channelId,
|
| 205 |
+
...(credentialId ? { credential_id: credentialId } : {}),
|
| 206 |
+
...(requestMode ? { request_mode: requestMode } : {})
|
| 207 |
+
};
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
function normalizeDiagnosticRequestMode(value: unknown): ChannelRequestMode | undefined {
|
| 211 |
+
if (typeof value !== 'string') return undefined;
|
| 212 |
+
return CHANNEL_REQUEST_MODES.includes(value as ChannelRequestMode) ? (value as ChannelRequestMode) : undefined;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function normalizeNonEmptyString(value: unknown): string | undefined {
|
| 216 |
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
function readNumberField(error: unknown, field: string): number | undefined {
|
| 220 |
if (typeof error !== 'object' || error === null || !(field in error)) return undefined;
|
| 221 |
const value = (error as Record<string, unknown>)[field];
|
src/lib/channel-health-prober.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type ChannelHealthProbeRecord = {
|
|
| 36 |
scope: 'credential' | 'channel';
|
| 37 |
channelId: string;
|
| 38 |
credentialId: string;
|
|
|
|
| 39 |
ok: boolean;
|
| 40 |
status?: number;
|
| 41 |
code?: string;
|
|
@@ -186,7 +187,7 @@ export function createChannelHealthProber(options: ChannelHealthProberOptions):
|
|
| 186 |
}
|
| 187 |
|
| 188 |
function toCandidateKey(candidate: ChannelRecoveryProbeCandidate): string {
|
| 189 |
-
return `${candidate.scope}:${candidate.credential.id}:${candidate.unhealthyUntil}`;
|
| 190 |
}
|
| 191 |
|
| 192 |
function toFailureCandidate(
|
|
@@ -234,6 +235,7 @@ function toProbeRecord(candidate: ChannelRecoveryProbeCandidate, result: ProbeRe
|
|
| 234 |
scope: candidate.scope,
|
| 235 |
channelId: candidate.credential.channelId,
|
| 236 |
credentialId: candidate.credential.id,
|
|
|
|
| 237 |
ok: result.ok,
|
| 238 |
...(result.status === undefined ? {} : { status: result.status }),
|
| 239 |
...(result.code === undefined ? {} : { code: result.code })
|
|
@@ -248,6 +250,7 @@ function toFailureReason(
|
|
| 248 |
return {
|
| 249 |
at,
|
| 250 |
scope: candidate.scope,
|
|
|
|
| 251 |
...(result.status === undefined ? {} : { status: result.status }),
|
| 252 |
...(result.code === undefined ? {} : { code: result.code })
|
| 253 |
};
|
|
|
|
| 36 |
scope: 'credential' | 'channel';
|
| 37 |
channelId: string;
|
| 38 |
credentialId: string;
|
| 39 |
+
requestMode?: ChannelRecoveryProbeCandidate['requestMode'];
|
| 40 |
ok: boolean;
|
| 41 |
status?: number;
|
| 42 |
code?: string;
|
|
|
|
| 187 |
}
|
| 188 |
|
| 189 |
function toCandidateKey(candidate: ChannelRecoveryProbeCandidate): string {
|
| 190 |
+
return `${candidate.scope}:${candidate.credential.id}:${candidate.requestMode ?? ''}:${candidate.unhealthyUntil}`;
|
| 191 |
}
|
| 192 |
|
| 193 |
function toFailureCandidate(
|
|
|
|
| 235 |
scope: candidate.scope,
|
| 236 |
channelId: candidate.credential.channelId,
|
| 237 |
credentialId: candidate.credential.id,
|
| 238 |
+
...(candidate.requestMode ? { requestMode: candidate.requestMode } : {}),
|
| 239 |
ok: result.ok,
|
| 240 |
...(result.status === undefined ? {} : { status: result.status }),
|
| 241 |
...(result.code === undefined ? {} : { code: result.code })
|
|
|
|
| 250 |
return {
|
| 251 |
at,
|
| 252 |
scope: candidate.scope,
|
| 253 |
+
...(candidate.requestMode ? { requestMode: candidate.requestMode } : {}),
|
| 254 |
...(result.status === undefined ? {} : { status: result.status }),
|
| 255 |
...(result.code === undefined ? {} : { code: result.code })
|
| 256 |
};
|
src/lib/channel-request-mode-values.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const CHANNEL_REQUEST_MODES = Object.freeze([
|
| 2 |
+
'images-non-stream',
|
| 3 |
+
'images-sse',
|
| 4 |
+
'responses-non-stream',
|
| 5 |
+
'responses-sse'
|
| 6 |
+
]);
|
| 7 |
+
|
| 8 |
+
export const CHANNEL_REQUEST_MODE_SMOKE_CASES = Object.freeze({
|
| 9 |
+
'images-non-stream': Object.freeze(['generate_1k', 'edit_1k']),
|
| 10 |
+
'images-sse': Object.freeze(['page_sse_edit_2k']),
|
| 11 |
+
'responses-non-stream': Object.freeze(['responses_agent_generate_1k']),
|
| 12 |
+
'responses-sse': Object.freeze(['responses_page_sse_generate_1k'])
|
| 13 |
+
});
|
| 14 |
+
|
| 15 |
+
export const CHANNEL_REQUEST_MODE_ADMIN_CONTROL = Object.freeze({
|
| 16 |
+
source: 'admin_env_whitelist',
|
| 17 |
+
globalEnv: 'OPENAI_UPSTREAM_REQUEST_MODES',
|
| 18 |
+
channelEnvPattern: 'OPENAI_CHANNEL_N_REQUEST_MODES',
|
| 19 |
+
mutableAtRuntime: false,
|
| 20 |
+
finalGateCommand:
|
| 21 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable',
|
| 22 |
+
smokeGateCommands: Object.freeze({
|
| 23 |
+
'images-non-stream': Object.freeze([
|
| 24 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case original-images-json --allow-billable'
|
| 25 |
+
]),
|
| 26 |
+
'images-sse': Object.freeze([
|
| 27 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-images-sse --allow-billable'
|
| 28 |
+
]),
|
| 29 |
+
'responses-non-stream': Object.freeze([
|
| 30 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case sub2api-responses-json --allow-billable'
|
| 31 |
+
]),
|
| 32 |
+
'responses-sse': Object.freeze([
|
| 33 |
+
'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --case gpt2image-responses-sse --allow-billable'
|
| 34 |
+
])
|
| 35 |
+
})
|
| 36 |
+
});
|
src/lib/channel-request-mode.ts
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { RequestValidationError } from './image-request-utils';
|
| 2 |
+
import {
|
| 3 |
+
CHANNEL_REQUEST_MODES as SHARED_CHANNEL_REQUEST_MODES,
|
| 4 |
+
CHANNEL_REQUEST_MODE_ADMIN_CONTROL as SHARED_CHANNEL_REQUEST_MODE_ADMIN_CONTROL
|
| 5 |
+
} from './channel-request-mode-values.mjs';
|
| 6 |
+
|
| 7 |
+
export const CHANNEL_REQUEST_MODES = SHARED_CHANNEL_REQUEST_MODES as readonly [
|
| 8 |
+
'images-non-stream',
|
| 9 |
+
'images-sse',
|
| 10 |
+
'responses-non-stream',
|
| 11 |
+
'responses-sse'
|
| 12 |
+
];
|
| 13 |
+
|
| 14 |
+
export type ChannelRequestMode = (typeof CHANNEL_REQUEST_MODES)[number];
|
| 15 |
+
|
| 16 |
+
export type ChannelRequestModeBackend = 'images-api' | 'responses-image-generation';
|
| 17 |
+
|
| 18 |
+
export type ChannelRequestModeDecision = {
|
| 19 |
+
requested_backend: ChannelRequestModeBackend;
|
| 20 |
+
preferred_channel_request_mode?: ChannelRequestMode;
|
| 21 |
+
fallback_channel_request_mode?: ChannelRequestMode;
|
| 22 |
+
selected_channel_request_mode?: ChannelRequestMode;
|
| 23 |
+
fallback_applied: boolean;
|
| 24 |
+
selected_channel_id?: string;
|
| 25 |
+
upstream_host?: string;
|
| 26 |
+
no_channel_reason?: string;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
export type ChannelRequestModeHealthSummary = {
|
| 30 |
+
configuredRequestModes: readonly ChannelRequestMode[];
|
| 31 |
+
effectiveRequestModes: readonly ChannelRequestMode[];
|
| 32 |
+
modes: Array<{
|
| 33 |
+
mode: ChannelRequestMode;
|
| 34 |
+
configuredCredentialCount: number;
|
| 35 |
+
healthyCredentialCount: number;
|
| 36 |
+
configuredChannelCount: number;
|
| 37 |
+
healthyChannelCount: number;
|
| 38 |
+
}>;
|
| 39 |
+
effectiveRequestModesByChannel: Array<{
|
| 40 |
+
channelId: string;
|
| 41 |
+
requestModes: readonly ChannelRequestMode[];
|
| 42 |
+
}>;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
export const DEFAULT_CHANNEL_REQUEST_MODES: readonly ChannelRequestMode[] = CHANNEL_REQUEST_MODES;
|
| 46 |
+
export const CHANNEL_REQUEST_MODE_ADMIN_CONTROL = SHARED_CHANNEL_REQUEST_MODE_ADMIN_CONTROL as {
|
| 47 |
+
readonly source: 'admin_env_whitelist';
|
| 48 |
+
readonly globalEnv: 'OPENAI_UPSTREAM_REQUEST_MODES';
|
| 49 |
+
readonly channelEnvPattern: 'OPENAI_CHANNEL_N_REQUEST_MODES';
|
| 50 |
+
readonly mutableAtRuntime: false;
|
| 51 |
+
readonly finalGateCommand: string;
|
| 52 |
+
readonly smokeGateCommands: Record<ChannelRequestMode, readonly string[]>;
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
const CHANNEL_REQUEST_MODE_SET = new Set<string>(CHANNEL_REQUEST_MODES);
|
| 56 |
+
const CHANNEL_REQUEST_MODE_ALIASES: Record<string, ChannelRequestMode> = {
|
| 57 |
+
'images-api': 'images-non-stream',
|
| 58 |
+
'images-api-non-stream': 'images-non-stream',
|
| 59 |
+
'images-api-json': 'images-non-stream',
|
| 60 |
+
'images-json': 'images-non-stream',
|
| 61 |
+
'images-nonstream': 'images-non-stream',
|
| 62 |
+
'images-api-sse': 'images-sse',
|
| 63 |
+
'images-stream': 'images-sse',
|
| 64 |
+
'images-api-stream': 'images-sse',
|
| 65 |
+
responses: 'responses-non-stream',
|
| 66 |
+
'responses-json': 'responses-non-stream',
|
| 67 |
+
'responses-nonstream': 'responses-non-stream',
|
| 68 |
+
'responses-image-generation': 'responses-non-stream',
|
| 69 |
+
'responses-image-generation-non-stream': 'responses-non-stream',
|
| 70 |
+
'responses-image-generation-sse': 'responses-sse',
|
| 71 |
+
'responses-stream': 'responses-sse'
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
export function parseChannelRequestModes(
|
| 75 |
+
value: string | undefined,
|
| 76 |
+
fieldName: string
|
| 77 |
+
): ChannelRequestMode[] | undefined {
|
| 78 |
+
if (!value?.trim()) return undefined;
|
| 79 |
+
const modes: ChannelRequestMode[] = [];
|
| 80 |
+
for (const rawPart of value.split(/[,\s]+/)) {
|
| 81 |
+
const normalized = normalizeChannelRequestMode(rawPart);
|
| 82 |
+
if (!normalized) continue;
|
| 83 |
+
if (!modes.includes(normalized)) {
|
| 84 |
+
modes.push(normalized);
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
if (modes.length === 0) {
|
| 88 |
+
throw new RequestValidationError(`${fieldName} 至少需要包含一个请求方式。`, 500);
|
| 89 |
+
}
|
| 90 |
+
return modes;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export function getEffectiveChannelRequestModes(input: {
|
| 94 |
+
requestModes?: readonly ChannelRequestMode[];
|
| 95 |
+
}): readonly ChannelRequestMode[] {
|
| 96 |
+
return input.requestModes?.length ? input.requestModes : DEFAULT_CHANNEL_REQUEST_MODES;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
export function channelSupportsRequestMode(
|
| 100 |
+
input: { requestModes?: readonly ChannelRequestMode[] },
|
| 101 |
+
mode: ChannelRequestMode
|
| 102 |
+
): boolean {
|
| 103 |
+
return getEffectiveChannelRequestModes(input).includes(mode);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export function resolveChannelRequestMode(input: {
|
| 107 |
+
imageBackend: ChannelRequestModeBackend;
|
| 108 |
+
streamEnabled: boolean;
|
| 109 |
+
}): ChannelRequestMode {
|
| 110 |
+
if (input.imageBackend === 'responses-image-generation') {
|
| 111 |
+
return input.streamEnabled ? 'responses-sse' : 'responses-non-stream';
|
| 112 |
+
}
|
| 113 |
+
return input.streamEnabled ? 'images-sse' : 'images-non-stream';
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
export function isStreamingChannelRequestMode(mode: ChannelRequestMode): boolean {
|
| 117 |
+
return mode.endsWith('-sse');
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
function normalizeChannelRequestMode(value: string): ChannelRequestMode | undefined {
|
| 121 |
+
const normalized = value.trim().toLowerCase().replace(/_/g, '-');
|
| 122 |
+
if (!normalized) return undefined;
|
| 123 |
+
if (CHANNEL_REQUEST_MODE_SET.has(normalized)) return normalized as ChannelRequestMode;
|
| 124 |
+
const aliased = CHANNEL_REQUEST_MODE_ALIASES[normalized];
|
| 125 |
+
if (aliased) return aliased;
|
| 126 |
+
throw new RequestValidationError(
|
| 127 |
+
`请求方式 ${value} 无效,必须是 ${CHANNEL_REQUEST_MODES.join(', ')} 之一。`,
|
| 128 |
+
500
|
| 129 |
+
);
|
| 130 |
+
}
|
src/lib/channel-router.test.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
| 9 |
toPublicChannelFailure
|
| 10 |
} from './channel-router';
|
| 11 |
import { ChannelCapacityQueueError } from './channel-capacity-queue';
|
|
|
|
| 12 |
import { IMAGE_UPSTREAM_PROFILES } from './image-upstream-profile';
|
| 13 |
import { RequestValidationError } from './image-request-utils';
|
| 14 |
import assert from 'node:assert/strict';
|
|
@@ -154,6 +155,30 @@ describe('parseChannelPoolConfig', () => {
|
|
| 154 |
/OPENAI_CHANNEL_N/
|
| 155 |
);
|
| 156 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
});
|
| 158 |
|
| 159 |
describe('getChannelPoolSummary', () => {
|
|
@@ -180,6 +205,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 180 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 181 |
hasExtraHeaders: false,
|
| 182 |
requestHeaders: DEFAULT_HEADER_SUMMARY,
|
|
|
|
| 183 |
credentialCount: 2
|
| 184 |
},
|
| 185 |
{
|
|
@@ -189,6 +215,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 189 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 190 |
hasExtraHeaders: false,
|
| 191 |
requestHeaders: DEFAULT_HEADER_SUMMARY,
|
|
|
|
| 192 |
credentialCount: 1
|
| 193 |
}
|
| 194 |
]
|
|
@@ -221,6 +248,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 221 |
has_extra_headers: true,
|
| 222 |
configured_header_names: ['x-app-id', 'x-app-secret']
|
| 223 |
},
|
|
|
|
| 224 |
credentialCount: 1
|
| 225 |
}
|
| 226 |
]
|
|
@@ -328,6 +356,7 @@ describe('getChannelPoolSummary', () => {
|
|
| 328 |
effectiveProfile: config.credentials[0]?.providerProfile,
|
| 329 |
hasExtraHeaders: false,
|
| 330 |
requestHeaders: DEFAULT_HEADER_SUMMARY,
|
|
|
|
| 331 |
providerManifest: {
|
| 332 |
id: 'custom_provider',
|
| 333 |
name: 'Custom Provider',
|
|
@@ -414,6 +443,25 @@ describe('createChannelRouter', () => {
|
|
| 414 |
assert.equal(router.select().id, 'b#0');
|
| 415 |
});
|
| 416 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
it('skips a failed credential until the cooldown expires', () => {
|
| 418 |
let now = 1000;
|
| 419 |
const router = createChannelRouter({
|
|
@@ -602,6 +650,221 @@ describe('createChannelRouter', () => {
|
|
| 602 |
});
|
| 603 |
});
|
| 604 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
it('uses per-channel cooldown when a channel defines a longer window', () => {
|
| 606 |
let now = 1000;
|
| 607 |
const channelConfig = parseChannelPoolConfig({
|
|
@@ -830,6 +1093,177 @@ describe('createChannelRouter', () => {
|
|
| 830 |
});
|
| 831 |
});
|
| 832 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 833 |
it('ignores stale recovery probe success after the credential fails again', () => {
|
| 834 |
let now = 1000;
|
| 835 |
const router = createChannelRouter({
|
|
|
|
| 9 |
toPublicChannelFailure
|
| 10 |
} from './channel-router';
|
| 11 |
import { ChannelCapacityQueueError } from './channel-capacity-queue';
|
| 12 |
+
import { CHANNEL_REQUEST_MODES } from './channel-request-mode';
|
| 13 |
import { IMAGE_UPSTREAM_PROFILES } from './image-upstream-profile';
|
| 14 |
import { RequestValidationError } from './image-request-utils';
|
| 15 |
import assert from 'node:assert/strict';
|
|
|
|
| 155 |
/OPENAI_CHANNEL_N/
|
| 156 |
);
|
| 157 |
});
|
| 158 |
+
|
| 159 |
+
it('parses explicit per-channel request modes', () => {
|
| 160 |
+
const config = parseChannelPoolConfig({
|
| 161 |
+
OPENAI_CHANNEL_1_ID: 'images-only',
|
| 162 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1',
|
| 163 |
+
OPENAI_CHANNEL_1_API_KEYS: 'sk-one',
|
| 164 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-json, images-sse'
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
assert.deepEqual(config.credentials[0]?.requestModes, ['images-non-stream', 'images-sse']);
|
| 168 |
+
});
|
| 169 |
+
|
| 170 |
+
it('rejects invalid per-channel request modes', () => {
|
| 171 |
+
assert.throws(
|
| 172 |
+
() =>
|
| 173 |
+
parseChannelPoolConfig({
|
| 174 |
+
OPENAI_CHANNEL_1_ID: 'bad',
|
| 175 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://bad.example.com/v1',
|
| 176 |
+
OPENAI_CHANNEL_1_API_KEYS: 'sk-one',
|
| 177 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'responses-websocket'
|
| 178 |
+
}),
|
| 179 |
+
/请求方式/
|
| 180 |
+
);
|
| 181 |
+
});
|
| 182 |
});
|
| 183 |
|
| 184 |
describe('getChannelPoolSummary', () => {
|
|
|
|
| 205 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 206 |
hasExtraHeaders: false,
|
| 207 |
requestHeaders: DEFAULT_HEADER_SUMMARY,
|
| 208 |
+
requestModes: CHANNEL_REQUEST_MODES,
|
| 209 |
credentialCount: 2
|
| 210 |
},
|
| 211 |
{
|
|
|
|
| 215 |
effectiveProfile: IMAGE_UPSTREAM_PROFILES['openai-compatible'],
|
| 216 |
hasExtraHeaders: false,
|
| 217 |
requestHeaders: DEFAULT_HEADER_SUMMARY,
|
| 218 |
+
requestModes: CHANNEL_REQUEST_MODES,
|
| 219 |
credentialCount: 1
|
| 220 |
}
|
| 221 |
]
|
|
|
|
| 248 |
has_extra_headers: true,
|
| 249 |
configured_header_names: ['x-app-id', 'x-app-secret']
|
| 250 |
},
|
| 251 |
+
requestModes: CHANNEL_REQUEST_MODES,
|
| 252 |
credentialCount: 1
|
| 253 |
}
|
| 254 |
]
|
|
|
|
| 356 |
effectiveProfile: config.credentials[0]?.providerProfile,
|
| 357 |
hasExtraHeaders: false,
|
| 358 |
requestHeaders: DEFAULT_HEADER_SUMMARY,
|
| 359 |
+
requestModes: CHANNEL_REQUEST_MODES,
|
| 360 |
providerManifest: {
|
| 361 |
id: 'custom_provider',
|
| 362 |
name: 'Custom Provider',
|
|
|
|
| 443 |
assert.equal(router.select().id, 'b#0');
|
| 444 |
});
|
| 445 |
|
| 446 |
+
it('selects only channels that support the requested mode', () => {
|
| 447 |
+
const modeConfig = parseChannelPoolConfig({
|
| 448 |
+
OPENAI_ROUTING_STRATEGY: 'round_robin',
|
| 449 |
+
OPENAI_CHANNEL_1_ID: 'images',
|
| 450 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1',
|
| 451 |
+
OPENAI_CHANNEL_1_API_KEYS: 'sk-images',
|
| 452 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream',
|
| 453 |
+
OPENAI_CHANNEL_2_ID: 'responses',
|
| 454 |
+
OPENAI_CHANNEL_2_BASE_URL: 'https://responses.example.com/v1',
|
| 455 |
+
OPENAI_CHANNEL_2_API_KEYS: 'sk-responses',
|
| 456 |
+
OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-sse'
|
| 457 |
+
});
|
| 458 |
+
const router = createChannelRouter(modeConfig);
|
| 459 |
+
|
| 460 |
+
assert.equal(router.select({ requestMode: 'responses-sse' }).channelId, 'responses');
|
| 461 |
+
assert.equal(router.select({ requestMode: 'images-non-stream' }).channelId, 'images');
|
| 462 |
+
assert.throws(() => router.select({ requestMode: 'images-sse' }), /支持 images-sse/);
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
it('skips a failed credential until the cooldown expires', () => {
|
| 466 |
let now = 1000;
|
| 467 |
const router = createChannelRouter({
|
|
|
|
| 650 |
});
|
| 651 |
});
|
| 652 |
|
| 653 |
+
it('reports configured and effective request modes separately', () => {
|
| 654 |
+
let now = 1000;
|
| 655 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 656 |
+
OPENAI_CHANNEL_1_ID: 'images',
|
| 657 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://images.example.com/v1',
|
| 658 |
+
OPENAI_CHANNEL_1_API_KEYS: 'images-key',
|
| 659 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse',
|
| 660 |
+
OPENAI_CHANNEL_2_ID: 'responses',
|
| 661 |
+
OPENAI_CHANNEL_2_BASE_URL: 'https://responses.example.com/v1',
|
| 662 |
+
OPENAI_CHANNEL_2_API_KEYS: 'responses-key',
|
| 663 |
+
OPENAI_CHANNEL_2_REQUEST_MODES: 'responses-sse'
|
| 664 |
+
});
|
| 665 |
+
const router = createChannelRouter({
|
| 666 |
+
...requestModeConfig,
|
| 667 |
+
failureCooldownMs: 100,
|
| 668 |
+
now: () => now
|
| 669 |
+
});
|
| 670 |
+
|
| 671 |
+
assert.deepEqual(router.getRequestModeHealthSummary(), {
|
| 672 |
+
configuredRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'],
|
| 673 |
+
effectiveRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'],
|
| 674 |
+
modes: [
|
| 675 |
+
{
|
| 676 |
+
mode: 'images-non-stream',
|
| 677 |
+
configuredCredentialCount: 1,
|
| 678 |
+
healthyCredentialCount: 1,
|
| 679 |
+
configuredChannelCount: 1,
|
| 680 |
+
healthyChannelCount: 1
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
mode: 'images-sse',
|
| 684 |
+
configuredCredentialCount: 1,
|
| 685 |
+
healthyCredentialCount: 1,
|
| 686 |
+
configuredChannelCount: 1,
|
| 687 |
+
healthyChannelCount: 1
|
| 688 |
+
},
|
| 689 |
+
{
|
| 690 |
+
mode: 'responses-non-stream',
|
| 691 |
+
configuredCredentialCount: 0,
|
| 692 |
+
healthyCredentialCount: 0,
|
| 693 |
+
configuredChannelCount: 0,
|
| 694 |
+
healthyChannelCount: 0
|
| 695 |
+
},
|
| 696 |
+
{
|
| 697 |
+
mode: 'responses-sse',
|
| 698 |
+
configuredCredentialCount: 1,
|
| 699 |
+
healthyCredentialCount: 1,
|
| 700 |
+
configuredChannelCount: 1,
|
| 701 |
+
healthyChannelCount: 1
|
| 702 |
+
}
|
| 703 |
+
],
|
| 704 |
+
effectiveRequestModesByChannel: [
|
| 705 |
+
{
|
| 706 |
+
channelId: 'images',
|
| 707 |
+
requestModes: ['images-non-stream', 'images-sse']
|
| 708 |
+
},
|
| 709 |
+
{
|
| 710 |
+
channelId: 'responses',
|
| 711 |
+
requestModes: ['responses-sse']
|
| 712 |
+
}
|
| 713 |
+
]
|
| 714 |
+
});
|
| 715 |
+
|
| 716 |
+
router.reportFailure(requestModeConfig.credentials[1], { scope: 'channel' });
|
| 717 |
+
assert.deepEqual(router.getRequestModeHealthSummary(), {
|
| 718 |
+
configuredRequestModes: ['images-non-stream', 'images-sse', 'responses-sse'],
|
| 719 |
+
effectiveRequestModes: ['images-non-stream', 'images-sse'],
|
| 720 |
+
modes: [
|
| 721 |
+
{
|
| 722 |
+
mode: 'images-non-stream',
|
| 723 |
+
configuredCredentialCount: 1,
|
| 724 |
+
healthyCredentialCount: 1,
|
| 725 |
+
configuredChannelCount: 1,
|
| 726 |
+
healthyChannelCount: 1
|
| 727 |
+
},
|
| 728 |
+
{
|
| 729 |
+
mode: 'images-sse',
|
| 730 |
+
configuredCredentialCount: 1,
|
| 731 |
+
healthyCredentialCount: 1,
|
| 732 |
+
configuredChannelCount: 1,
|
| 733 |
+
healthyChannelCount: 1
|
| 734 |
+
},
|
| 735 |
+
{
|
| 736 |
+
mode: 'responses-non-stream',
|
| 737 |
+
configuredCredentialCount: 0,
|
| 738 |
+
healthyCredentialCount: 0,
|
| 739 |
+
configuredChannelCount: 0,
|
| 740 |
+
healthyChannelCount: 0
|
| 741 |
+
},
|
| 742 |
+
{
|
| 743 |
+
mode: 'responses-sse',
|
| 744 |
+
configuredCredentialCount: 1,
|
| 745 |
+
healthyCredentialCount: 0,
|
| 746 |
+
configuredChannelCount: 1,
|
| 747 |
+
healthyChannelCount: 0
|
| 748 |
+
}
|
| 749 |
+
],
|
| 750 |
+
effectiveRequestModesByChannel: [
|
| 751 |
+
{
|
| 752 |
+
channelId: 'images',
|
| 753 |
+
requestModes: ['images-non-stream', 'images-sse']
|
| 754 |
+
}
|
| 755 |
+
]
|
| 756 |
+
});
|
| 757 |
+
|
| 758 |
+
now = 1100;
|
| 759 |
+
assert.deepEqual(router.getRequestModeHealthSummary().effectiveRequestModes, [
|
| 760 |
+
'images-non-stream',
|
| 761 |
+
'images-sse',
|
| 762 |
+
'responses-sse'
|
| 763 |
+
]);
|
| 764 |
+
});
|
| 765 |
+
|
| 766 |
+
it('cools only the failed request mode when failure reports include a request mode', () => {
|
| 767 |
+
let now = 1000;
|
| 768 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 769 |
+
OPENAI_CHANNEL_1_ID: 'mixed',
|
| 770 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1',
|
| 771 |
+
OPENAI_CHANNEL_1_API_KEYS: 'mixed-key',
|
| 772 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse'
|
| 773 |
+
});
|
| 774 |
+
const router = createChannelRouter({
|
| 775 |
+
...requestModeConfig,
|
| 776 |
+
failureCooldownMs: 100,
|
| 777 |
+
now: () => now
|
| 778 |
+
});
|
| 779 |
+
const credential = requestModeConfig.credentials[0];
|
| 780 |
+
|
| 781 |
+
const report = router.reportFailure(credential, {
|
| 782 |
+
scope: 'channel',
|
| 783 |
+
requestMode: 'images-sse',
|
| 784 |
+
reason: {
|
| 785 |
+
at: now,
|
| 786 |
+
scope: 'channel',
|
| 787 |
+
status: 524
|
| 788 |
+
}
|
| 789 |
+
});
|
| 790 |
+
|
| 791 |
+
assert.deepEqual(report.target, {
|
| 792 |
+
channelId: 'mixed',
|
| 793 |
+
requestMode: 'images-sse'
|
| 794 |
+
});
|
| 795 |
+
assert.deepEqual(report.reason, {
|
| 796 |
+
at: 1000,
|
| 797 |
+
scope: 'channel',
|
| 798 |
+
status: 524,
|
| 799 |
+
requestMode: 'images-sse'
|
| 800 |
+
});
|
| 801 |
+
assert.throws(() => router.select({ requestMode: 'images-sse' }), /支持 images-sse/);
|
| 802 |
+
assert.equal(router.select({ requestMode: 'images-non-stream' }).id, credential.id);
|
| 803 |
+
assert.deepEqual(router.getHealthSummary(), {
|
| 804 |
+
credentialCount: 1,
|
| 805 |
+
healthyCredentialCount: 1,
|
| 806 |
+
unhealthyCredentialCount: 0,
|
| 807 |
+
channelCount: 1,
|
| 808 |
+
healthyChannelCount: 1,
|
| 809 |
+
unhealthyChannelCount: 0,
|
| 810 |
+
pendingRecoveryProbeCredentialCount: 0,
|
| 811 |
+
pendingRecoveryProbeChannelCount: 0,
|
| 812 |
+
lastFailure: {
|
| 813 |
+
at: 1000,
|
| 814 |
+
scope: 'channel',
|
| 815 |
+
status: 524,
|
| 816 |
+
requestMode: 'images-sse'
|
| 817 |
+
}
|
| 818 |
+
});
|
| 819 |
+
assert.deepEqual(router.getRequestModeHealthSummary(), {
|
| 820 |
+
configuredRequestModes: ['images-non-stream', 'images-sse'],
|
| 821 |
+
effectiveRequestModes: ['images-non-stream'],
|
| 822 |
+
modes: [
|
| 823 |
+
{
|
| 824 |
+
mode: 'images-non-stream',
|
| 825 |
+
configuredCredentialCount: 1,
|
| 826 |
+
healthyCredentialCount: 1,
|
| 827 |
+
configuredChannelCount: 1,
|
| 828 |
+
healthyChannelCount: 1
|
| 829 |
+
},
|
| 830 |
+
{
|
| 831 |
+
mode: 'images-sse',
|
| 832 |
+
configuredCredentialCount: 1,
|
| 833 |
+
healthyCredentialCount: 0,
|
| 834 |
+
configuredChannelCount: 1,
|
| 835 |
+
healthyChannelCount: 0
|
| 836 |
+
},
|
| 837 |
+
{
|
| 838 |
+
mode: 'responses-non-stream',
|
| 839 |
+
configuredCredentialCount: 0,
|
| 840 |
+
healthyCredentialCount: 0,
|
| 841 |
+
configuredChannelCount: 0,
|
| 842 |
+
healthyChannelCount: 0
|
| 843 |
+
},
|
| 844 |
+
{
|
| 845 |
+
mode: 'responses-sse',
|
| 846 |
+
configuredCredentialCount: 0,
|
| 847 |
+
healthyCredentialCount: 0,
|
| 848 |
+
configuredChannelCount: 0,
|
| 849 |
+
healthyChannelCount: 0
|
| 850 |
+
}
|
| 851 |
+
],
|
| 852 |
+
effectiveRequestModesByChannel: [
|
| 853 |
+
{
|
| 854 |
+
channelId: 'mixed',
|
| 855 |
+
requestModes: ['images-non-stream']
|
| 856 |
+
}
|
| 857 |
+
]
|
| 858 |
+
});
|
| 859 |
+
|
| 860 |
+
now = 1100;
|
| 861 |
+
assert.equal(router.select({ requestMode: 'images-sse' }).id, credential.id);
|
| 862 |
+
assert.deepEqual(router.getRequestModeHealthSummary().effectiveRequestModes, [
|
| 863 |
+
'images-non-stream',
|
| 864 |
+
'images-sse'
|
| 865 |
+
]);
|
| 866 |
+
});
|
| 867 |
+
|
| 868 |
it('uses per-channel cooldown when a channel defines a longer window', () => {
|
| 869 |
let now = 1000;
|
| 870 |
const channelConfig = parseChannelPoolConfig({
|
|
|
|
| 1093 |
});
|
| 1094 |
});
|
| 1095 |
|
| 1096 |
+
it('requires recovery probes for request-mode cooldowns', () => {
|
| 1097 |
+
let now = 1000;
|
| 1098 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 1099 |
+
OPENAI_CHANNEL_1_ID: 'mixed',
|
| 1100 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1',
|
| 1101 |
+
OPENAI_CHANNEL_1_API_KEYS: 'mixed-key',
|
| 1102 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse'
|
| 1103 |
+
});
|
| 1104 |
+
const router = createChannelRouter({
|
| 1105 |
+
...requestModeConfig,
|
| 1106 |
+
failureCooldownMs: 100,
|
| 1107 |
+
now: () => now,
|
| 1108 |
+
requireProbeForRecovery: true
|
| 1109 |
+
});
|
| 1110 |
+
const failed = requestModeConfig.credentials[0];
|
| 1111 |
+
|
| 1112 |
+
router.reportFailure(failed, { scope: 'channel', requestMode: 'images-sse' });
|
| 1113 |
+
|
| 1114 |
+
now = 1100;
|
| 1115 |
+
assert.equal(router.select({ requestMode: 'images-non-stream' }).id, failed.id);
|
| 1116 |
+
assert.throws(() => router.select({ requestMode: 'images-sse' }), /支持 images-sse/);
|
| 1117 |
+
assert.deepEqual(
|
| 1118 |
+
router.getRecoveryProbeCandidates().map((candidate) => ({
|
| 1119 |
+
scope: candidate.scope,
|
| 1120 |
+
credentialId: candidate.credential.id,
|
| 1121 |
+
requestMode: candidate.requestMode
|
| 1122 |
+
})),
|
| 1123 |
+
[{ scope: 'channel', credentialId: 'mixed#0', requestMode: 'images-sse' }]
|
| 1124 |
+
);
|
| 1125 |
+
assert.deepEqual(router.getHealthSummary(), {
|
| 1126 |
+
credentialCount: 1,
|
| 1127 |
+
healthyCredentialCount: 1,
|
| 1128 |
+
unhealthyCredentialCount: 0,
|
| 1129 |
+
channelCount: 1,
|
| 1130 |
+
healthyChannelCount: 1,
|
| 1131 |
+
unhealthyChannelCount: 0,
|
| 1132 |
+
pendingRecoveryProbeCredentialCount: 0,
|
| 1133 |
+
pendingRecoveryProbeChannelCount: 1,
|
| 1134 |
+
lastFailure: {
|
| 1135 |
+
at: 1000,
|
| 1136 |
+
scope: 'channel',
|
| 1137 |
+
requestMode: 'images-sse'
|
| 1138 |
+
}
|
| 1139 |
+
});
|
| 1140 |
+
|
| 1141 |
+
const candidate = router.getRecoveryProbeCandidates()[0];
|
| 1142 |
+
assert.equal(router.reportRecoveryProbeSuccess(candidate), true);
|
| 1143 |
+
assert.equal(router.select({ requestMode: 'images-sse' }).id, failed.id);
|
| 1144 |
+
});
|
| 1145 |
+
|
| 1146 |
+
it('uses a request-mode-capable sibling credential for channel request-mode recovery probes', () => {
|
| 1147 |
+
let now = 1000;
|
| 1148 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 1149 |
+
OPENAI_CHANNEL_1_ID: 'mixed',
|
| 1150 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1',
|
| 1151 |
+
OPENAI_CHANNEL_1_API_KEYS: 'mixed-a,mixed-b',
|
| 1152 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse'
|
| 1153 |
+
});
|
| 1154 |
+
const router = createChannelRouter({
|
| 1155 |
+
...requestModeConfig,
|
| 1156 |
+
failureCooldownMs: 100,
|
| 1157 |
+
now: () => now,
|
| 1158 |
+
requireProbeForRecovery: true
|
| 1159 |
+
});
|
| 1160 |
+
const [first, second] = requestModeConfig.credentials;
|
| 1161 |
+
|
| 1162 |
+
router.reportFailure(first, { scope: 'channel', requestMode: 'images-sse' });
|
| 1163 |
+
now = 1010;
|
| 1164 |
+
router.reportFailure(first, { requestMode: 'images-sse' });
|
| 1165 |
+
|
| 1166 |
+
now = 1100;
|
| 1167 |
+
assert.deepEqual(
|
| 1168 |
+
router.getRecoveryProbeCandidates().map((candidate) => ({
|
| 1169 |
+
scope: candidate.scope,
|
| 1170 |
+
credentialId: candidate.credential.id,
|
| 1171 |
+
requestMode: candidate.requestMode
|
| 1172 |
+
})),
|
| 1173 |
+
[{ scope: 'channel', credentialId: second.id, requestMode: 'images-sse' }]
|
| 1174 |
+
);
|
| 1175 |
+
assert.equal(router.reportRecoveryProbeSuccess(router.getRecoveryProbeCandidates()[0]), true);
|
| 1176 |
+
assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 1);
|
| 1177 |
+
});
|
| 1178 |
+
|
| 1179 |
+
it('queues the channel recovery probe before request-mode probes for the same channel', () => {
|
| 1180 |
+
let now = 1000;
|
| 1181 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 1182 |
+
OPENAI_CHANNEL_1_ID: 'mixed',
|
| 1183 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1',
|
| 1184 |
+
OPENAI_CHANNEL_1_API_KEYS: 'mixed-key',
|
| 1185 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse'
|
| 1186 |
+
});
|
| 1187 |
+
const router = createChannelRouter({
|
| 1188 |
+
...requestModeConfig,
|
| 1189 |
+
failureCooldownMs: 100,
|
| 1190 |
+
now: () => now,
|
| 1191 |
+
requireProbeForRecovery: true
|
| 1192 |
+
});
|
| 1193 |
+
const failed = requestModeConfig.credentials[0];
|
| 1194 |
+
|
| 1195 |
+
router.reportFailure(failed, { scope: 'channel' });
|
| 1196 |
+
router.reportFailure(failed, { scope: 'channel', requestMode: 'images-sse' });
|
| 1197 |
+
|
| 1198 |
+
now = 1100;
|
| 1199 |
+
assert.deepEqual(
|
| 1200 |
+
router.getRecoveryProbeCandidates().map((candidate) => ({
|
| 1201 |
+
scope: candidate.scope,
|
| 1202 |
+
credentialId: candidate.credential.id,
|
| 1203 |
+
requestMode: candidate.requestMode
|
| 1204 |
+
})),
|
| 1205 |
+
[{ scope: 'channel', credentialId: failed.id, requestMode: undefined }]
|
| 1206 |
+
);
|
| 1207 |
+
});
|
| 1208 |
+
|
| 1209 |
+
it('queues the credential recovery probe before request-mode probes for the same credential', () => {
|
| 1210 |
+
let now = 1000;
|
| 1211 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 1212 |
+
OPENAI_CHANNEL_1_ID: 'mixed',
|
| 1213 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1',
|
| 1214 |
+
OPENAI_CHANNEL_1_API_KEYS: 'mixed-key',
|
| 1215 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse'
|
| 1216 |
+
});
|
| 1217 |
+
const router = createChannelRouter({
|
| 1218 |
+
...requestModeConfig,
|
| 1219 |
+
failureCooldownMs: 100,
|
| 1220 |
+
now: () => now,
|
| 1221 |
+
requireProbeForRecovery: true
|
| 1222 |
+
});
|
| 1223 |
+
const failed = requestModeConfig.credentials[0];
|
| 1224 |
+
|
| 1225 |
+
router.reportFailure(failed);
|
| 1226 |
+
router.reportFailure(failed, { requestMode: 'images-sse' });
|
| 1227 |
+
|
| 1228 |
+
now = 1100;
|
| 1229 |
+
assert.deepEqual(
|
| 1230 |
+
router.getRecoveryProbeCandidates().map((candidate) => ({
|
| 1231 |
+
scope: candidate.scope,
|
| 1232 |
+
credentialId: candidate.credential.id,
|
| 1233 |
+
requestMode: candidate.requestMode
|
| 1234 |
+
})),
|
| 1235 |
+
[{ scope: 'credential', credentialId: failed.id, requestMode: undefined }]
|
| 1236 |
+
);
|
| 1237 |
+
});
|
| 1238 |
+
|
| 1239 |
+
it('clears older credential request-mode probes when a channel request-mode probe recovers', () => {
|
| 1240 |
+
let now = 1000;
|
| 1241 |
+
const requestModeConfig = parseChannelPoolConfig({
|
| 1242 |
+
OPENAI_CHANNEL_1_ID: 'mixed',
|
| 1243 |
+
OPENAI_CHANNEL_1_BASE_URL: 'https://mixed.example.com/v1',
|
| 1244 |
+
OPENAI_CHANNEL_1_API_KEYS: 'mixed-a,mixed-b',
|
| 1245 |
+
OPENAI_CHANNEL_1_REQUEST_MODES: 'images-non-stream,images-sse'
|
| 1246 |
+
});
|
| 1247 |
+
const router = createChannelRouter({
|
| 1248 |
+
...requestModeConfig,
|
| 1249 |
+
failureCooldownMs: 100,
|
| 1250 |
+
now: () => now,
|
| 1251 |
+
requireProbeForRecovery: true
|
| 1252 |
+
});
|
| 1253 |
+
const [first, second] = requestModeConfig.credentials;
|
| 1254 |
+
|
| 1255 |
+
router.reportFailure(first, { requestMode: 'images-sse' });
|
| 1256 |
+
router.reportFailure(second, { requestMode: 'images-sse' });
|
| 1257 |
+
now = 1010;
|
| 1258 |
+
router.reportFailure(first, { scope: 'channel', requestMode: 'images-sse' });
|
| 1259 |
+
|
| 1260 |
+
now = 1110;
|
| 1261 |
+
const candidate = router.getRecoveryProbeCandidates()[0];
|
| 1262 |
+
assert.equal(router.reportRecoveryProbeSuccess(candidate), true);
|
| 1263 |
+
assert.equal(router.getHealthSummary().pendingRecoveryProbeCredentialCount, 0);
|
| 1264 |
+
assert.equal(router.select({ requestMode: 'images-sse' }).channelId, 'mixed');
|
| 1265 |
+
});
|
| 1266 |
+
|
| 1267 |
it('ignores stale recovery probe success after the credential fails again', () => {
|
| 1268 |
let now = 1000;
|
| 1269 |
const router = createChannelRouter({
|
src/lib/channel-router.ts
CHANGED
|
@@ -17,6 +17,14 @@ import {
|
|
| 17 |
type ImageProviderManifest,
|
| 18 |
type ImageProviderManifestSummary
|
| 19 |
} from './image-upstream-provider-manifest';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
import { ChannelCapacityQueueError } from './channel-capacity-queue';
|
| 21 |
import { RequestValidationError, readPlainHttpApiBaseUrlAllowlist, validateApiBaseUrl } from './image-request-utils';
|
| 22 |
|
|
@@ -32,6 +40,7 @@ export type ChannelCredential = {
|
|
| 32 |
providerManifest?: ImageProviderManifestSummary;
|
| 33 |
providerProfile?: ImageUpstreamProfile;
|
| 34 |
failureCooldownMs?: number;
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
export type ChannelPoolConfig = {
|
|
@@ -51,21 +60,24 @@ export type ChannelPoolSummary = {
|
|
| 51 |
hasExtraHeaders: boolean;
|
| 52 |
requestHeaders: ReturnType<typeof summarizeUpstreamRequestHeaders>;
|
| 53 |
providerManifest?: ImageProviderManifestSummary;
|
|
|
|
| 54 |
credentialCount: number;
|
| 55 |
}>;
|
| 56 |
};
|
| 57 |
|
| 58 |
export type ChannelRouter = {
|
| 59 |
-
select(options?: { affinityKey?: string }): ChannelCredential;
|
| 60 |
reportFailure(credential: ChannelCredential, options?: ChannelFailureReportOptions): ChannelFailureReport;
|
| 61 |
getRecoveryProbeCandidates(): ChannelRecoveryProbeCandidate[];
|
| 62 |
reportRecoveryProbeSuccess(candidate: ChannelRecoveryProbeCandidate): boolean;
|
| 63 |
reportRecoveryProbeFailure(candidate: ChannelRecoveryProbeCandidate, reason?: ChannelFailureReason): void;
|
| 64 |
getHealthSummary(): ChannelPoolHealthSummary;
|
|
|
|
| 65 |
};
|
| 66 |
|
| 67 |
export type ChannelFailureReportOptions = {
|
| 68 |
scope?: 'credential' | 'channel';
|
|
|
|
| 69 |
reason?: ChannelFailureReason;
|
| 70 |
};
|
| 71 |
|
|
@@ -77,6 +89,7 @@ export type ChannelFailureReport = {
|
|
| 77 |
target: {
|
| 78 |
channelId: string;
|
| 79 |
credentialId?: string;
|
|
|
|
| 80 |
};
|
| 81 |
reason: ChannelFailureReason;
|
| 82 |
};
|
|
@@ -87,6 +100,7 @@ export type ChannelFailureReason = {
|
|
| 87 |
status?: number;
|
| 88 |
code?: string;
|
| 89 |
requestId?: string;
|
|
|
|
| 90 |
message?: string;
|
| 91 |
};
|
| 92 |
|
|
@@ -108,6 +122,7 @@ export type ChannelRecoveryProbeCandidate = {
|
|
| 108 |
scope: 'credential' | 'channel';
|
| 109 |
credential: ChannelCredential;
|
| 110 |
unhealthyUntil: number;
|
|
|
|
| 111 |
};
|
| 112 |
|
| 113 |
export type EffectiveCredential = {
|
|
@@ -131,7 +146,7 @@ const DEFAULT_STRATEGY: RoutingStrategy = 'sticky';
|
|
| 131 |
const DEFAULT_FAILURE_COOLDOWN_MS = 30_000;
|
| 132 |
const VALID_STRATEGIES = new Set<RoutingStrategy>(['sticky', 'round_robin', 'random']);
|
| 133 |
const CHANNEL_KEY_PATTERN =
|
| 134 |
-
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|API_KEYS|UPSTREAM_PROFILE|PROVIDER_MANIFEST|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON|FAILURE_COOLDOWN_MS)$/;
|
| 135 |
|
| 136 |
export function parseChannelPoolConfig(env: Record<string, string | undefined>): ChannelPoolConfig {
|
| 137 |
if (env.OPENAI_CHANNELS_JSON?.trim()) {
|
|
@@ -168,22 +183,41 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 168 |
const failureCooldownMs = Math.max(1, Math.floor(options.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS));
|
| 169 |
const unhealthyUntilByCredentialId = new Map<string, number>();
|
| 170 |
const unhealthyUntilByChannelId = new Map<string, number>();
|
|
|
|
|
|
|
| 171 |
const probeRequiredCredentialIds = new Set<string>();
|
| 172 |
const probeRequiredChannelIds = new Set<string>();
|
|
|
|
|
|
|
| 173 |
const channelIds = Array.from(new Set(options.credentials.map((credential) => credential.channelId)));
|
| 174 |
let lastFailure: ChannelFailureReason | undefined;
|
| 175 |
|
| 176 |
-
const isCoolingDown = (credential: ChannelCredential) => {
|
| 177 |
const currentTime = now();
|
| 178 |
return (
|
| 179 |
(unhealthyUntilByCredentialId.get(credential.id) ?? 0) > currentTime ||
|
| 180 |
-
(unhealthyUntilByChannelId.get(credential.channelId) ?? 0) > currentTime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
);
|
| 182 |
};
|
| 183 |
|
| 184 |
-
const isWaitingForProbe = (credential: ChannelCredential) => {
|
| 185 |
if (!options.requireProbeForRecovery) return false;
|
| 186 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
};
|
| 188 |
|
| 189 |
const isHealthy = (credential: ChannelCredential) => {
|
|
@@ -191,9 +225,16 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 191 |
};
|
| 192 |
|
| 193 |
const healthyCredentials = () => options.credentials.filter(isHealthy);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
const setCooldown = (
|
| 195 |
credential: ChannelCredential,
|
| 196 |
-
scope: 'credential' | 'channel'
|
|
|
|
| 197 |
): { cooldownApplied: boolean; cooldownUntil: number; retryAfterMs: number } => {
|
| 198 |
if (!failureCooldownEnabled) {
|
| 199 |
return { cooldownApplied: false, cooldownUntil: now(), retryAfterMs: 0 };
|
|
@@ -201,24 +242,66 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 201 |
const cooldownMs = credential.failureCooldownMs ?? failureCooldownMs;
|
| 202 |
const unhealthyUntil = now() + cooldownMs;
|
| 203 |
if (scope === 'channel') {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 204 |
unhealthyUntilByChannelId.set(credential.channelId, unhealthyUntil);
|
| 205 |
if (options.requireProbeForRecovery) probeRequiredChannelIds.add(credential.channelId);
|
| 206 |
return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs };
|
| 207 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
unhealthyUntilByCredentialId.set(credential.id, unhealthyUntil);
|
| 209 |
if (options.requireProbeForRecovery) probeRequiredCredentialIds.add(credential.id);
|
| 210 |
return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs };
|
| 211 |
};
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
return {
|
| 214 |
select(selectOptions = {}) {
|
| 215 |
-
const
|
|
|
|
|
|
|
|
|
|
| 216 |
if (candidates.length === 0) {
|
| 217 |
-
throw new RequestValidationError(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
}
|
| 219 |
|
| 220 |
if (options.strategy === 'round_robin') {
|
| 221 |
-
const credential = selectRoundRobinHealthy(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
nextIndex = credential.nextIndex;
|
| 223 |
return credential.value;
|
| 224 |
}
|
|
@@ -232,18 +315,27 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 232 |
const startIndex = stableHash(affinityKey) % options.credentials.length;
|
| 233 |
for (let offset = 0; offset < options.credentials.length; offset += 1) {
|
| 234 |
const credential = options.credentials[(startIndex + offset) % options.credentials.length];
|
| 235 |
-
if (
|
| 236 |
return credential;
|
| 237 |
}
|
| 238 |
}
|
| 239 |
|
| 240 |
-
throw new RequestValidationError(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
},
|
| 242 |
reportFailure(credential: ChannelCredential, reportOptions = {}) {
|
| 243 |
const currentTime = now();
|
| 244 |
const scope = reportOptions.scope === 'channel' ? 'channel' : 'credential';
|
| 245 |
-
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
return {
|
| 248 |
scope,
|
| 249 |
cooldownApplied: cooldown.cooldownApplied,
|
|
@@ -251,7 +343,8 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 251 |
retryAfterMs: cooldown.retryAfterMs,
|
| 252 |
target: {
|
| 253 |
channelId: credential.channelId,
|
| 254 |
-
...(scope === 'credential' ? { credentialId: credential.id } : {})
|
|
|
|
| 255 |
},
|
| 256 |
reason: lastFailure
|
| 257 |
};
|
|
@@ -261,7 +354,9 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 261 |
const currentTime = now();
|
| 262 |
const candidates: ChannelRecoveryProbeCandidate[] = [];
|
| 263 |
const queuedChannelIds = new Set<string>();
|
|
|
|
| 264 |
const dueChannelIds = new Set<string>();
|
|
|
|
| 265 |
for (const credential of options.credentials) {
|
| 266 |
const channelUnhealthyUntil = unhealthyUntilByChannelId.get(credential.channelId) ?? 0;
|
| 267 |
const credentialUnhealthyUntil = unhealthyUntilByCredentialId.get(credential.id) ?? 0;
|
|
@@ -287,6 +382,71 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 287 |
}
|
| 288 |
}
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
for (const credential of options.credentials) {
|
| 291 |
const credentialUnhealthyUntil = unhealthyUntilByCredentialId.get(credential.id) ?? 0;
|
| 292 |
const credentialReady =
|
|
@@ -294,7 +454,7 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 294 |
credentialUnhealthyUntil <= currentTime &&
|
| 295 |
(!probeRequiredChannelIds.has(credential.channelId) ||
|
| 296 |
(dueChannelIds.has(credential.channelId) && !queuedChannelIds.has(credential.channelId)));
|
| 297 |
-
if (credentialReady) {
|
| 298 |
candidates.push({
|
| 299 |
scope: 'credential',
|
| 300 |
credential,
|
|
@@ -305,6 +465,29 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 305 |
return candidates;
|
| 306 |
},
|
| 307 |
reportRecoveryProbeSuccess(candidate: ChannelRecoveryProbeCandidate) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
if (candidate.scope === 'channel') {
|
| 309 |
if ((unhealthyUntilByChannelId.get(candidate.credential.channelId) ?? 0) !== candidate.unhealthyUntil) {
|
| 310 |
return false;
|
|
@@ -325,11 +508,14 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 325 |
return true;
|
| 326 |
},
|
| 327 |
reportRecoveryProbeFailure(candidate: ChannelRecoveryProbeCandidate, reason) {
|
| 328 |
-
lastFailure =
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
| 331 |
};
|
| 332 |
-
setCooldown(candidate.credential, candidate.scope);
|
| 333 |
},
|
| 334 |
getHealthSummary() {
|
| 335 |
const healthyCredentialCount = healthyCredentials().length;
|
|
@@ -343,18 +529,79 @@ export function createChannelRouter(options: ChannelRouterOptions): ChannelRoute
|
|
| 343 |
channelCount: channelIds.length,
|
| 344 |
healthyChannelCount,
|
| 345 |
unhealthyChannelCount: channelIds.length - healthyChannelCount,
|
| 346 |
-
pendingRecoveryProbeCredentialCount:
|
| 347 |
-
|
|
|
|
|
|
|
| 348 |
...(lastFailure ? { lastFailure } : {})
|
| 349 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
}
|
| 351 |
};
|
| 352 |
}
|
| 353 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
function selectRoundRobinHealthy(
|
| 355 |
credentials: ChannelCredential[],
|
| 356 |
startIndex: number,
|
| 357 |
-
isHealthy: (credential: ChannelCredential) => boolean
|
|
|
|
| 358 |
): { value: ChannelCredential; nextIndex: number } {
|
| 359 |
for (let offset = 0; offset < credentials.length; offset += 1) {
|
| 360 |
const index = (startIndex + offset) % credentials.length;
|
|
@@ -367,7 +614,12 @@ function selectRoundRobinHealthy(
|
|
| 367 |
}
|
| 368 |
}
|
| 369 |
|
| 370 |
-
throw new RequestValidationError(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 371 |
}
|
| 372 |
|
| 373 |
export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSummary {
|
|
@@ -381,6 +633,7 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
|
|
| 381 |
hasExtraHeaders: boolean;
|
| 382 |
requestHeaders: ReturnType<typeof summarizeUpstreamRequestHeaders>;
|
| 383 |
providerManifest?: ImageProviderManifestSummary;
|
|
|
|
| 384 |
credentialCount: number;
|
| 385 |
}
|
| 386 |
>();
|
|
@@ -399,6 +652,7 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
|
|
| 399 |
hasExtraHeaders: Boolean(credential.upstreamHeaders),
|
| 400 |
requestHeaders: summarizeUpstreamRequestHeaders(credential.upstreamHeaders),
|
| 401 |
...(credential.providerManifest ? { providerManifest: credential.providerManifest } : {}),
|
|
|
|
| 402 |
credentialCount: 1
|
| 403 |
});
|
| 404 |
});
|
|
@@ -411,6 +665,51 @@ export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSum
|
|
| 411 |
};
|
| 412 |
}
|
| 413 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
export function resolveEffectiveCredential(options: {
|
| 415 |
requestApiKey: string;
|
| 416 |
requestApiBaseUrl: string;
|
|
@@ -512,6 +811,7 @@ function parseLegacyConfig(env: Record<string, string | undefined>): ChannelPool
|
|
| 512 |
if (rawProfile && !isValidImageUpstreamProfileId(rawProfile)) {
|
| 513 |
throw new RequestValidationError('OPENAI_UPSTREAM_PROFILE 必须是 openai-compatible 或 matsca。', 500);
|
| 514 |
}
|
|
|
|
| 515 |
|
| 516 |
return {
|
| 517 |
strategy: DEFAULT_STRATEGY,
|
|
@@ -525,7 +825,8 @@ function parseLegacyConfig(env: Record<string, string | undefined>): ChannelPool
|
|
| 525 |
explicitProfile: rawProfile,
|
| 526 |
channelId: 'default',
|
| 527 |
baseUrl
|
| 528 |
-
}).id
|
|
|
|
| 529 |
}
|
| 530 |
]
|
| 531 |
};
|
|
@@ -540,6 +841,10 @@ function parseNumberedChannel(env: Record<string, string | undefined>, channelIn
|
|
| 540 |
const providerManifest = readChannelProviderManifest(env, channelIndex, upstreamProfile);
|
| 541 |
const providerProfile = providerManifest ? createProviderManifestProfile(providerManifest) : undefined;
|
| 542 |
const failureCooldownMs = readOptionalPositiveIntegerEnv(env, `OPENAI_CHANNEL_${channelIndex}_FAILURE_COOLDOWN_MS`);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
if (baseUrl) {
|
| 544 |
validateApiBaseUrl(baseUrl, {
|
| 545 |
allowedPlainHttpBaseUrls: readPlainHttpApiBaseUrlAllowlist(env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS)
|
|
@@ -563,7 +868,8 @@ function parseNumberedChannel(env: Record<string, string | undefined>, channelIn
|
|
| 563 |
...(upstreamHeaders ? { upstreamHeaders } : {}),
|
| 564 |
...(providerManifest ? { providerManifest: createProviderManifestSummary(providerManifest) } : {}),
|
| 565 |
...(providerProfile ? { providerProfile } : {}),
|
| 566 |
-
...(failureCooldownMs ? { failureCooldownMs } : {})
|
|
|
|
| 567 |
}));
|
| 568 |
}
|
| 569 |
|
|
|
|
| 17 |
type ImageProviderManifest,
|
| 18 |
type ImageProviderManifestSummary
|
| 19 |
} from './image-upstream-provider-manifest';
|
| 20 |
+
import {
|
| 21 |
+
CHANNEL_REQUEST_MODES,
|
| 22 |
+
channelSupportsRequestMode,
|
| 23 |
+
getEffectiveChannelRequestModes,
|
| 24 |
+
parseChannelRequestModes,
|
| 25 |
+
type ChannelRequestMode,
|
| 26 |
+
type ChannelRequestModeHealthSummary
|
| 27 |
+
} from './channel-request-mode';
|
| 28 |
import { ChannelCapacityQueueError } from './channel-capacity-queue';
|
| 29 |
import { RequestValidationError, readPlainHttpApiBaseUrlAllowlist, validateApiBaseUrl } from './image-request-utils';
|
| 30 |
|
|
|
|
| 40 |
providerManifest?: ImageProviderManifestSummary;
|
| 41 |
providerProfile?: ImageUpstreamProfile;
|
| 42 |
failureCooldownMs?: number;
|
| 43 |
+
requestModes?: ChannelRequestMode[];
|
| 44 |
};
|
| 45 |
|
| 46 |
export type ChannelPoolConfig = {
|
|
|
|
| 60 |
hasExtraHeaders: boolean;
|
| 61 |
requestHeaders: ReturnType<typeof summarizeUpstreamRequestHeaders>;
|
| 62 |
providerManifest?: ImageProviderManifestSummary;
|
| 63 |
+
requestModes: readonly ChannelRequestMode[];
|
| 64 |
credentialCount: number;
|
| 65 |
}>;
|
| 66 |
};
|
| 67 |
|
| 68 |
export type ChannelRouter = {
|
| 69 |
+
select(options?: { affinityKey?: string; requestMode?: ChannelRequestMode }): ChannelCredential;
|
| 70 |
reportFailure(credential: ChannelCredential, options?: ChannelFailureReportOptions): ChannelFailureReport;
|
| 71 |
getRecoveryProbeCandidates(): ChannelRecoveryProbeCandidate[];
|
| 72 |
reportRecoveryProbeSuccess(candidate: ChannelRecoveryProbeCandidate): boolean;
|
| 73 |
reportRecoveryProbeFailure(candidate: ChannelRecoveryProbeCandidate, reason?: ChannelFailureReason): void;
|
| 74 |
getHealthSummary(): ChannelPoolHealthSummary;
|
| 75 |
+
getRequestModeHealthSummary(): ChannelRequestModeHealthSummary;
|
| 76 |
};
|
| 77 |
|
| 78 |
export type ChannelFailureReportOptions = {
|
| 79 |
scope?: 'credential' | 'channel';
|
| 80 |
+
requestMode?: ChannelRequestMode;
|
| 81 |
reason?: ChannelFailureReason;
|
| 82 |
};
|
| 83 |
|
|
|
|
| 89 |
target: {
|
| 90 |
channelId: string;
|
| 91 |
credentialId?: string;
|
| 92 |
+
requestMode?: ChannelRequestMode;
|
| 93 |
};
|
| 94 |
reason: ChannelFailureReason;
|
| 95 |
};
|
|
|
|
| 100 |
status?: number;
|
| 101 |
code?: string;
|
| 102 |
requestId?: string;
|
| 103 |
+
requestMode?: ChannelRequestMode;
|
| 104 |
message?: string;
|
| 105 |
};
|
| 106 |
|
|
|
|
| 122 |
scope: 'credential' | 'channel';
|
| 123 |
credential: ChannelCredential;
|
| 124 |
unhealthyUntil: number;
|
| 125 |
+
requestMode?: ChannelRequestMode;
|
| 126 |
};
|
| 127 |
|
| 128 |
export type EffectiveCredential = {
|
|
|
|
| 146 |
const DEFAULT_FAILURE_COOLDOWN_MS = 30_000;
|
| 147 |
const VALID_STRATEGIES = new Set<RoutingStrategy>(['sticky', 'round_robin', 'random']);
|
| 148 |
const CHANNEL_KEY_PATTERN =
|
| 149 |
+
/^OPENAI_CHANNEL_(\d+)_(ID|BASE_URL|API_KEYS|UPSTREAM_PROFILE|PROVIDER_MANIFEST|REQUEST_MODES|MATSCA_APP_ID|MATSCA_APP_SECRET|USER_AGENT|UPSTREAM_HEADERS_JSON|FAILURE_COOLDOWN_MS)$/;
|
| 150 |
|
| 151 |
export function parseChannelPoolConfig(env: Record<string, string | undefined>): ChannelPoolConfig {
|
| 152 |
if (env.OPENAI_CHANNELS_JSON?.trim()) {
|
|
|
|
| 183 |
const failureCooldownMs = Math.max(1, Math.floor(options.failureCooldownMs ?? DEFAULT_FAILURE_COOLDOWN_MS));
|
| 184 |
const unhealthyUntilByCredentialId = new Map<string, number>();
|
| 185 |
const unhealthyUntilByChannelId = new Map<string, number>();
|
| 186 |
+
const unhealthyUntilByCredentialRequestMode = new Map<string, number>();
|
| 187 |
+
const unhealthyUntilByChannelRequestMode = new Map<string, number>();
|
| 188 |
const probeRequiredCredentialIds = new Set<string>();
|
| 189 |
const probeRequiredChannelIds = new Set<string>();
|
| 190 |
+
const probeRequiredCredentialRequestModes = new Set<string>();
|
| 191 |
+
const probeRequiredChannelRequestModes = new Set<string>();
|
| 192 |
const channelIds = Array.from(new Set(options.credentials.map((credential) => credential.channelId)));
|
| 193 |
let lastFailure: ChannelFailureReason | undefined;
|
| 194 |
|
| 195 |
+
const isCoolingDown = (credential: ChannelCredential, requestMode?: ChannelRequestMode) => {
|
| 196 |
const currentTime = now();
|
| 197 |
return (
|
| 198 |
(unhealthyUntilByCredentialId.get(credential.id) ?? 0) > currentTime ||
|
| 199 |
+
(unhealthyUntilByChannelId.get(credential.channelId) ?? 0) > currentTime ||
|
| 200 |
+
Boolean(
|
| 201 |
+
requestMode &&
|
| 202 |
+
((unhealthyUntilByCredentialRequestMode.get(credentialRequestModeKey(credential, requestMode)) ?? 0) >
|
| 203 |
+
currentTime ||
|
| 204 |
+
(unhealthyUntilByChannelRequestMode.get(channelRequestModeKey(credential, requestMode)) ?? 0) >
|
| 205 |
+
currentTime)
|
| 206 |
+
)
|
| 207 |
);
|
| 208 |
};
|
| 209 |
|
| 210 |
+
const isWaitingForProbe = (credential: ChannelCredential, requestMode?: ChannelRequestMode) => {
|
| 211 |
if (!options.requireProbeForRecovery) return false;
|
| 212 |
+
return (
|
| 213 |
+
probeRequiredCredentialIds.has(credential.id) ||
|
| 214 |
+
probeRequiredChannelIds.has(credential.channelId) ||
|
| 215 |
+
Boolean(
|
| 216 |
+
requestMode &&
|
| 217 |
+
(probeRequiredCredentialRequestModes.has(credentialRequestModeKey(credential, requestMode)) ||
|
| 218 |
+
probeRequiredChannelRequestModes.has(channelRequestModeKey(credential, requestMode)))
|
| 219 |
+
)
|
| 220 |
+
);
|
| 221 |
};
|
| 222 |
|
| 223 |
const isHealthy = (credential: ChannelCredential) => {
|
|
|
|
| 225 |
};
|
| 226 |
|
| 227 |
const healthyCredentials = () => options.credentials.filter(isHealthy);
|
| 228 |
+
const supportsRequestedMode = (credential: ChannelCredential, mode: ChannelRequestMode | undefined) => {
|
| 229 |
+
return !mode || channelSupportsRequestMode(credential, mode);
|
| 230 |
+
};
|
| 231 |
+
const isHealthyForRequestMode = (credential: ChannelCredential, mode: ChannelRequestMode | undefined) => {
|
| 232 |
+
return supportsRequestedMode(credential, mode) && !isCoolingDown(credential, mode) && !isWaitingForProbe(credential, mode);
|
| 233 |
+
};
|
| 234 |
const setCooldown = (
|
| 235 |
credential: ChannelCredential,
|
| 236 |
+
scope: 'credential' | 'channel',
|
| 237 |
+
requestMode?: ChannelRequestMode
|
| 238 |
): { cooldownApplied: boolean; cooldownUntil: number; retryAfterMs: number } => {
|
| 239 |
if (!failureCooldownEnabled) {
|
| 240 |
return { cooldownApplied: false, cooldownUntil: now(), retryAfterMs: 0 };
|
|
|
|
| 242 |
const cooldownMs = credential.failureCooldownMs ?? failureCooldownMs;
|
| 243 |
const unhealthyUntil = now() + cooldownMs;
|
| 244 |
if (scope === 'channel') {
|
| 245 |
+
if (requestMode) {
|
| 246 |
+
unhealthyUntilByChannelRequestMode.set(channelRequestModeKey(credential, requestMode), unhealthyUntil);
|
| 247 |
+
if (options.requireProbeForRecovery) {
|
| 248 |
+
probeRequiredChannelRequestModes.add(channelRequestModeKey(credential, requestMode));
|
| 249 |
+
}
|
| 250 |
+
return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs };
|
| 251 |
+
}
|
| 252 |
unhealthyUntilByChannelId.set(credential.channelId, unhealthyUntil);
|
| 253 |
if (options.requireProbeForRecovery) probeRequiredChannelIds.add(credential.channelId);
|
| 254 |
return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs };
|
| 255 |
}
|
| 256 |
+
if (requestMode) {
|
| 257 |
+
unhealthyUntilByCredentialRequestMode.set(credentialRequestModeKey(credential, requestMode), unhealthyUntil);
|
| 258 |
+
if (options.requireProbeForRecovery) {
|
| 259 |
+
probeRequiredCredentialRequestModes.add(credentialRequestModeKey(credential, requestMode));
|
| 260 |
+
}
|
| 261 |
+
return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs };
|
| 262 |
+
}
|
| 263 |
unhealthyUntilByCredentialId.set(credential.id, unhealthyUntil);
|
| 264 |
if (options.requireProbeForRecovery) probeRequiredCredentialIds.add(credential.id);
|
| 265 |
return { cooldownApplied: true, cooldownUntil: unhealthyUntil, retryAfterMs: cooldownMs };
|
| 266 |
};
|
| 267 |
|
| 268 |
+
const clearCredentialRequestModeCooldownsForChannel = (
|
| 269 |
+
channelId: string,
|
| 270 |
+
requestMode: ChannelRequestMode,
|
| 271 |
+
unhealthyUntil: number
|
| 272 |
+
) => {
|
| 273 |
+
for (const credential of options.credentials) {
|
| 274 |
+
if (credential.channelId !== channelId) continue;
|
| 275 |
+
const key = credentialRequestModeKey(credential, requestMode);
|
| 276 |
+
if ((unhealthyUntilByCredentialRequestMode.get(key) ?? 0) <= unhealthyUntil) {
|
| 277 |
+
unhealthyUntilByCredentialRequestMode.delete(key);
|
| 278 |
+
probeRequiredCredentialRequestModes.delete(key);
|
| 279 |
+
}
|
| 280 |
+
}
|
| 281 |
+
};
|
| 282 |
+
|
| 283 |
return {
|
| 284 |
select(selectOptions = {}) {
|
| 285 |
+
const requestMode = selectOptions.requestMode;
|
| 286 |
+
const candidates = options.credentials.filter((credential) =>
|
| 287 |
+
isHealthyForRequestMode(credential, requestMode)
|
| 288 |
+
);
|
| 289 |
if (candidates.length === 0) {
|
| 290 |
+
throw new RequestValidationError(
|
| 291 |
+
requestMode
|
| 292 |
+
? `当前没有支持 ${requestMode} 的健康渠道凭证。请调整请求策略或 OPENAI_CHANNEL_N_REQUEST_MODES。`
|
| 293 |
+
: '当前没有可用的健康渠道凭证。',
|
| 294 |
+
503
|
| 295 |
+
);
|
| 296 |
}
|
| 297 |
|
| 298 |
if (options.strategy === 'round_robin') {
|
| 299 |
+
const credential = selectRoundRobinHealthy(
|
| 300 |
+
options.credentials,
|
| 301 |
+
nextIndex,
|
| 302 |
+
(candidate) => isHealthyForRequestMode(candidate, requestMode),
|
| 303 |
+
requestMode
|
| 304 |
+
);
|
| 305 |
nextIndex = credential.nextIndex;
|
| 306 |
return credential.value;
|
| 307 |
}
|
|
|
|
| 315 |
const startIndex = stableHash(affinityKey) % options.credentials.length;
|
| 316 |
for (let offset = 0; offset < options.credentials.length; offset += 1) {
|
| 317 |
const credential = options.credentials[(startIndex + offset) % options.credentials.length];
|
| 318 |
+
if (isHealthyForRequestMode(credential, requestMode)) {
|
| 319 |
return credential;
|
| 320 |
}
|
| 321 |
}
|
| 322 |
|
| 323 |
+
throw new RequestValidationError(
|
| 324 |
+
requestMode
|
| 325 |
+
? `当前没有支持 ${requestMode} 的健康渠道凭证。请调整请求策略或 OPENAI_CHANNEL_N_REQUEST_MODES。`
|
| 326 |
+
: '当前没有可用的健康渠道凭证。',
|
| 327 |
+
503
|
| 328 |
+
);
|
| 329 |
},
|
| 330 |
reportFailure(credential: ChannelCredential, reportOptions = {}) {
|
| 331 |
const currentTime = now();
|
| 332 |
const scope = reportOptions.scope === 'channel' ? 'channel' : 'credential';
|
| 333 |
+
const requestMode = reportOptions.requestMode;
|
| 334 |
+
lastFailure = {
|
| 335 |
+
...(reportOptions.reason ?? { at: currentTime, scope }),
|
| 336 |
+
...(requestMode ? { requestMode } : {})
|
| 337 |
+
};
|
| 338 |
+
const cooldown = setCooldown(credential, scope, requestMode);
|
| 339 |
return {
|
| 340 |
scope,
|
| 341 |
cooldownApplied: cooldown.cooldownApplied,
|
|
|
|
| 343 |
retryAfterMs: cooldown.retryAfterMs,
|
| 344 |
target: {
|
| 345 |
channelId: credential.channelId,
|
| 346 |
+
...(scope === 'credential' ? { credentialId: credential.id } : {}),
|
| 347 |
+
...(requestMode ? { requestMode } : {})
|
| 348 |
},
|
| 349 |
reason: lastFailure
|
| 350 |
};
|
|
|
|
| 354 |
const currentTime = now();
|
| 355 |
const candidates: ChannelRecoveryProbeCandidate[] = [];
|
| 356 |
const queuedChannelIds = new Set<string>();
|
| 357 |
+
const queuedCredentialIds = new Set<string>();
|
| 358 |
const dueChannelIds = new Set<string>();
|
| 359 |
+
const dueChannelRequestModeKeys = new Set<string>();
|
| 360 |
for (const credential of options.credentials) {
|
| 361 |
const channelUnhealthyUntil = unhealthyUntilByChannelId.get(credential.channelId) ?? 0;
|
| 362 |
const credentialUnhealthyUntil = unhealthyUntilByCredentialId.get(credential.id) ?? 0;
|
|
|
|
| 382 |
}
|
| 383 |
}
|
| 384 |
|
| 385 |
+
for (const [key, channelUnhealthyUntil] of unhealthyUntilByChannelRequestMode.entries()) {
|
| 386 |
+
const parsed = readChannelRequestModeKey(key);
|
| 387 |
+
if (!parsed || channelUnhealthyUntil > currentTime) continue;
|
| 388 |
+
if (!probeRequiredChannelRequestModes.has(key)) continue;
|
| 389 |
+
dueChannelRequestModeKeys.add(key);
|
| 390 |
+
if (
|
| 391 |
+
probeRequiredChannelIds.has(parsed.channelId) ||
|
| 392 |
+
(unhealthyUntilByChannelId.get(parsed.channelId) ?? 0) > currentTime ||
|
| 393 |
+
queuedChannelIds.has(parsed.channelId) ||
|
| 394 |
+
queuedChannelIds.has(key)
|
| 395 |
+
) {
|
| 396 |
+
continue;
|
| 397 |
+
}
|
| 398 |
+
const credential = findChannelRequestModeProbeCredential(
|
| 399 |
+
options.credentials,
|
| 400 |
+
parsed.channelId,
|
| 401 |
+
parsed.requestMode,
|
| 402 |
+
unhealthyUntilByCredentialRequestMode,
|
| 403 |
+
unhealthyUntilByCredentialId,
|
| 404 |
+
probeRequiredCredentialIds,
|
| 405 |
+
currentTime
|
| 406 |
+
);
|
| 407 |
+
if (!credential) continue;
|
| 408 |
+
candidates.push({
|
| 409 |
+
scope: 'channel',
|
| 410 |
+
credential,
|
| 411 |
+
unhealthyUntil: channelUnhealthyUntil,
|
| 412 |
+
requestMode: parsed.requestMode
|
| 413 |
+
});
|
| 414 |
+
queuedChannelIds.add(key);
|
| 415 |
+
queuedCredentialIds.add(credential.id);
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
for (const [key, credentialUnhealthyUntil] of unhealthyUntilByCredentialRequestMode.entries()) {
|
| 419 |
+
const parsed = readCredentialRequestModeKey(key);
|
| 420 |
+
if (!parsed || credentialUnhealthyUntil > currentTime) continue;
|
| 421 |
+
if (!probeRequiredCredentialRequestModes.has(key)) continue;
|
| 422 |
+
const channelKey = `${parsed.channelId}${REQUEST_MODE_KEY_SEPARATOR}${parsed.requestMode}`;
|
| 423 |
+
if (
|
| 424 |
+
probeRequiredChannelIds.has(parsed.channelId) ||
|
| 425 |
+
(unhealthyUntilByChannelId.get(parsed.channelId) ?? 0) > currentTime ||
|
| 426 |
+
probeRequiredCredentialIds.has(parsed.credentialId) ||
|
| 427 |
+
(unhealthyUntilByCredentialId.get(parsed.credentialId) ?? 0) > currentTime ||
|
| 428 |
+
queuedChannelIds.has(parsed.channelId) ||
|
| 429 |
+
queuedCredentialIds.has(parsed.credentialId)
|
| 430 |
+
) {
|
| 431 |
+
continue;
|
| 432 |
+
}
|
| 433 |
+
if (
|
| 434 |
+
probeRequiredChannelRequestModes.has(channelKey) &&
|
| 435 |
+
(!dueChannelRequestModeKeys.has(channelKey) || queuedChannelIds.has(channelKey))
|
| 436 |
+
) {
|
| 437 |
+
continue;
|
| 438 |
+
}
|
| 439 |
+
const credential = options.credentials.find((candidate) => candidate.id === parsed.credentialId);
|
| 440 |
+
if (!credential) continue;
|
| 441 |
+
candidates.push({
|
| 442 |
+
scope: 'credential',
|
| 443 |
+
credential,
|
| 444 |
+
unhealthyUntil: credentialUnhealthyUntil,
|
| 445 |
+
requestMode: parsed.requestMode
|
| 446 |
+
});
|
| 447 |
+
queuedCredentialIds.add(credential.id);
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
for (const credential of options.credentials) {
|
| 451 |
const credentialUnhealthyUntil = unhealthyUntilByCredentialId.get(credential.id) ?? 0;
|
| 452 |
const credentialReady =
|
|
|
|
| 454 |
credentialUnhealthyUntil <= currentTime &&
|
| 455 |
(!probeRequiredChannelIds.has(credential.channelId) ||
|
| 456 |
(dueChannelIds.has(credential.channelId) && !queuedChannelIds.has(credential.channelId)));
|
| 457 |
+
if (credentialReady && !queuedCredentialIds.has(credential.id)) {
|
| 458 |
candidates.push({
|
| 459 |
scope: 'credential',
|
| 460 |
credential,
|
|
|
|
| 465 |
return candidates;
|
| 466 |
},
|
| 467 |
reportRecoveryProbeSuccess(candidate: ChannelRecoveryProbeCandidate) {
|
| 468 |
+
if (candidate.requestMode) {
|
| 469 |
+
if (candidate.scope === 'channel') {
|
| 470 |
+
const key = channelRequestModeKey(candidate.credential, candidate.requestMode);
|
| 471 |
+
if ((unhealthyUntilByChannelRequestMode.get(key) ?? 0) !== candidate.unhealthyUntil) {
|
| 472 |
+
return false;
|
| 473 |
+
}
|
| 474 |
+
unhealthyUntilByChannelRequestMode.delete(key);
|
| 475 |
+
probeRequiredChannelRequestModes.delete(key);
|
| 476 |
+
clearCredentialRequestModeCooldownsForChannel(
|
| 477 |
+
candidate.credential.channelId,
|
| 478 |
+
candidate.requestMode,
|
| 479 |
+
candidate.unhealthyUntil
|
| 480 |
+
);
|
| 481 |
+
return true;
|
| 482 |
+
}
|
| 483 |
+
const key = credentialRequestModeKey(candidate.credential, candidate.requestMode);
|
| 484 |
+
if ((unhealthyUntilByCredentialRequestMode.get(key) ?? 0) !== candidate.unhealthyUntil) {
|
| 485 |
+
return false;
|
| 486 |
+
}
|
| 487 |
+
unhealthyUntilByCredentialRequestMode.delete(key);
|
| 488 |
+
probeRequiredCredentialRequestModes.delete(key);
|
| 489 |
+
return true;
|
| 490 |
+
}
|
| 491 |
if (candidate.scope === 'channel') {
|
| 492 |
if ((unhealthyUntilByChannelId.get(candidate.credential.channelId) ?? 0) !== candidate.unhealthyUntil) {
|
| 493 |
return false;
|
|
|
|
| 508 |
return true;
|
| 509 |
},
|
| 510 |
reportRecoveryProbeFailure(candidate: ChannelRecoveryProbeCandidate, reason) {
|
| 511 |
+
lastFailure = {
|
| 512 |
+
...(reason ?? {
|
| 513 |
+
at: now(),
|
| 514 |
+
scope: candidate.scope
|
| 515 |
+
}),
|
| 516 |
+
...(candidate.requestMode ? { requestMode: candidate.requestMode } : {})
|
| 517 |
};
|
| 518 |
+
setCooldown(candidate.credential, candidate.scope, candidate.requestMode);
|
| 519 |
},
|
| 520 |
getHealthSummary() {
|
| 521 |
const healthyCredentialCount = healthyCredentials().length;
|
|
|
|
| 529 |
channelCount: channelIds.length,
|
| 530 |
healthyChannelCount,
|
| 531 |
unhealthyChannelCount: channelIds.length - healthyChannelCount,
|
| 532 |
+
pendingRecoveryProbeCredentialCount:
|
| 533 |
+
probeRequiredCredentialIds.size + probeRequiredCredentialRequestModes.size,
|
| 534 |
+
pendingRecoveryProbeChannelCount:
|
| 535 |
+
probeRequiredChannelIds.size + probeRequiredChannelRequestModes.size,
|
| 536 |
...(lastFailure ? { lastFailure } : {})
|
| 537 |
};
|
| 538 |
+
},
|
| 539 |
+
getRequestModeHealthSummary() {
|
| 540 |
+
return {
|
| 541 |
+
configuredRequestModes: summarizeCredentialRequestModes(options.credentials),
|
| 542 |
+
effectiveRequestModes: summarizeHealthyRequestModes(options.credentials, isHealthyForRequestMode),
|
| 543 |
+
modes: summarizeRequestModeCoverage(options.credentials, isHealthyForRequestMode),
|
| 544 |
+
effectiveRequestModesByChannel: summarizeHealthyRequestModesByChannel(options.credentials, isHealthyForRequestMode)
|
| 545 |
+
};
|
| 546 |
}
|
| 547 |
};
|
| 548 |
}
|
| 549 |
|
| 550 |
+
const REQUEST_MODE_KEY_SEPARATOR = '\u0000';
|
| 551 |
+
|
| 552 |
+
function channelRequestModeKey(credential: ChannelCredential, requestMode: ChannelRequestMode): string {
|
| 553 |
+
return `${credential.channelId}${REQUEST_MODE_KEY_SEPARATOR}${requestMode}`;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
function credentialRequestModeKey(credential: ChannelCredential, requestMode: ChannelRequestMode): string {
|
| 557 |
+
return `${credential.channelId}${REQUEST_MODE_KEY_SEPARATOR}${credential.id}${REQUEST_MODE_KEY_SEPARATOR}${requestMode}`;
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
function readChannelRequestModeKey(value: string): { channelId: string; requestMode: ChannelRequestMode } | undefined {
|
| 561 |
+
const [channelId, requestMode] = value.split(REQUEST_MODE_KEY_SEPARATOR);
|
| 562 |
+
if (!channelId || !isChannelRequestMode(requestMode)) return undefined;
|
| 563 |
+
return { channelId, requestMode };
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
function readCredentialRequestModeKey(value: string): {
|
| 567 |
+
channelId: string;
|
| 568 |
+
credentialId: string;
|
| 569 |
+
requestMode: ChannelRequestMode;
|
| 570 |
+
} | undefined {
|
| 571 |
+
const [channelId, credentialId, requestMode] = value.split(REQUEST_MODE_KEY_SEPARATOR);
|
| 572 |
+
if (!channelId || !credentialId || !isChannelRequestMode(requestMode)) return undefined;
|
| 573 |
+
return { channelId, credentialId, requestMode };
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
function isChannelRequestMode(value: string | undefined): value is ChannelRequestMode {
|
| 577 |
+
return Boolean(value && (CHANNEL_REQUEST_MODES as readonly string[]).includes(value));
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
function findChannelRequestModeProbeCredential(
|
| 581 |
+
credentials: ChannelCredential[],
|
| 582 |
+
channelId: string,
|
| 583 |
+
requestMode: ChannelRequestMode,
|
| 584 |
+
unhealthyUntilByCredentialRequestMode: Map<string, number>,
|
| 585 |
+
unhealthyUntilByCredentialId: Map<string, number>,
|
| 586 |
+
probeRequiredCredentialIds: Set<string>,
|
| 587 |
+
currentTime: number
|
| 588 |
+
): ChannelCredential | undefined {
|
| 589 |
+
return credentials.find(
|
| 590 |
+
(credential) =>
|
| 591 |
+
credential.channelId === channelId &&
|
| 592 |
+
channelSupportsRequestMode(credential, requestMode) &&
|
| 593 |
+
!probeRequiredCredentialIds.has(credential.id) &&
|
| 594 |
+
(unhealthyUntilByCredentialId.get(credential.id) ?? 0) <= currentTime &&
|
| 595 |
+
(unhealthyUntilByCredentialRequestMode.get(credentialRequestModeKey(credential, requestMode)) ?? 0) <=
|
| 596 |
+
currentTime
|
| 597 |
+
);
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
function selectRoundRobinHealthy(
|
| 601 |
credentials: ChannelCredential[],
|
| 602 |
startIndex: number,
|
| 603 |
+
isHealthy: (credential: ChannelCredential) => boolean,
|
| 604 |
+
requestMode?: ChannelRequestMode
|
| 605 |
): { value: ChannelCredential; nextIndex: number } {
|
| 606 |
for (let offset = 0; offset < credentials.length; offset += 1) {
|
| 607 |
const index = (startIndex + offset) % credentials.length;
|
|
|
|
| 614 |
}
|
| 615 |
}
|
| 616 |
|
| 617 |
+
throw new RequestValidationError(
|
| 618 |
+
requestMode
|
| 619 |
+
? `当前没有支持 ${requestMode} 的健康渠道凭证。请调整请求策略或 OPENAI_CHANNEL_N_REQUEST_MODES。`
|
| 620 |
+
: '当前没有可用的健康渠道凭证。',
|
| 621 |
+
503
|
| 622 |
+
);
|
| 623 |
}
|
| 624 |
|
| 625 |
export function getChannelPoolSummary(config: ChannelPoolConfig): ChannelPoolSummary {
|
|
|
|
| 633 |
hasExtraHeaders: boolean;
|
| 634 |
requestHeaders: ReturnType<typeof summarizeUpstreamRequestHeaders>;
|
| 635 |
providerManifest?: ImageProviderManifestSummary;
|
| 636 |
+
requestModes: readonly ChannelRequestMode[];
|
| 637 |
credentialCount: number;
|
| 638 |
}
|
| 639 |
>();
|
|
|
|
| 652 |
hasExtraHeaders: Boolean(credential.upstreamHeaders),
|
| 653 |
requestHeaders: summarizeUpstreamRequestHeaders(credential.upstreamHeaders),
|
| 654 |
...(credential.providerManifest ? { providerManifest: credential.providerManifest } : {}),
|
| 655 |
+
requestModes: getEffectiveChannelRequestModes(credential),
|
| 656 |
credentialCount: 1
|
| 657 |
});
|
| 658 |
});
|
|
|
|
| 665 |
};
|
| 666 |
}
|
| 667 |
|
| 668 |
+
function summarizeCredentialRequestModes(credentials: ChannelCredential[]): readonly ChannelRequestMode[] {
|
| 669 |
+
return CHANNEL_REQUEST_MODES.filter((mode) =>
|
| 670 |
+
credentials.some((credential) => getEffectiveChannelRequestModes(credential).includes(mode))
|
| 671 |
+
);
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
function summarizeHealthyRequestModes(
|
| 675 |
+
credentials: ChannelCredential[],
|
| 676 |
+
isHealthyForRequestMode: (credential: ChannelCredential, mode: ChannelRequestMode) => boolean
|
| 677 |
+
): readonly ChannelRequestMode[] {
|
| 678 |
+
return CHANNEL_REQUEST_MODES.filter((mode) =>
|
| 679 |
+
credentials.some((credential) => isHealthyForRequestMode(credential, mode))
|
| 680 |
+
);
|
| 681 |
+
}
|
| 682 |
+
|
| 683 |
+
function summarizeHealthyRequestModesByChannel(
|
| 684 |
+
credentials: ChannelCredential[],
|
| 685 |
+
isHealthyForRequestMode: (credential: ChannelCredential, mode: ChannelRequestMode) => boolean
|
| 686 |
+
): Array<{ channelId: string; requestModes: readonly ChannelRequestMode[] }> {
|
| 687 |
+
return Array.from(new Set(credentials.map((credential) => credential.channelId))).flatMap((channelId) => {
|
| 688 |
+
const channelCredentials = credentials.filter((credential) => credential.channelId === channelId);
|
| 689 |
+
const requestModes = summarizeHealthyRequestModes(channelCredentials, isHealthyForRequestMode);
|
| 690 |
+
return requestModes.length ? [{ channelId, requestModes }] : [];
|
| 691 |
+
});
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
function summarizeRequestModeCoverage(
|
| 695 |
+
credentials: ChannelCredential[],
|
| 696 |
+
isHealthyForRequestMode: (credential: ChannelCredential, mode: ChannelRequestMode) => boolean
|
| 697 |
+
): ChannelRequestModeHealthSummary['modes'] {
|
| 698 |
+
return CHANNEL_REQUEST_MODES.map((mode) => {
|
| 699 |
+
const configuredCredentials = credentials.filter((credential) =>
|
| 700 |
+
getEffectiveChannelRequestModes(credential).includes(mode)
|
| 701 |
+
);
|
| 702 |
+
const healthyModeCredentials = credentials.filter((credential) => isHealthyForRequestMode(credential, mode));
|
| 703 |
+
return {
|
| 704 |
+
mode,
|
| 705 |
+
configuredCredentialCount: configuredCredentials.length,
|
| 706 |
+
healthyCredentialCount: healthyModeCredentials.length,
|
| 707 |
+
configuredChannelCount: new Set(configuredCredentials.map((credential) => credential.channelId)).size,
|
| 708 |
+
healthyChannelCount: new Set(healthyModeCredentials.map((credential) => credential.channelId)).size
|
| 709 |
+
};
|
| 710 |
+
});
|
| 711 |
+
}
|
| 712 |
+
|
| 713 |
export function resolveEffectiveCredential(options: {
|
| 714 |
requestApiKey: string;
|
| 715 |
requestApiBaseUrl: string;
|
|
|
|
| 811 |
if (rawProfile && !isValidImageUpstreamProfileId(rawProfile)) {
|
| 812 |
throw new RequestValidationError('OPENAI_UPSTREAM_PROFILE 必须是 openai-compatible 或 matsca。', 500);
|
| 813 |
}
|
| 814 |
+
const requestModes = parseChannelRequestModes(env.OPENAI_UPSTREAM_REQUEST_MODES, 'OPENAI_UPSTREAM_REQUEST_MODES');
|
| 815 |
|
| 816 |
return {
|
| 817 |
strategy: DEFAULT_STRATEGY,
|
|
|
|
| 825 |
explicitProfile: rawProfile,
|
| 826 |
channelId: 'default',
|
| 827 |
baseUrl
|
| 828 |
+
}).id,
|
| 829 |
+
...(requestModes ? { requestModes } : {})
|
| 830 |
}
|
| 831 |
]
|
| 832 |
};
|
|
|
|
| 841 |
const providerManifest = readChannelProviderManifest(env, channelIndex, upstreamProfile);
|
| 842 |
const providerProfile = providerManifest ? createProviderManifestProfile(providerManifest) : undefined;
|
| 843 |
const failureCooldownMs = readOptionalPositiveIntegerEnv(env, `OPENAI_CHANNEL_${channelIndex}_FAILURE_COOLDOWN_MS`);
|
| 844 |
+
const requestModes = parseChannelRequestModes(
|
| 845 |
+
env[`OPENAI_CHANNEL_${channelIndex}_REQUEST_MODES`],
|
| 846 |
+
`OPENAI_CHANNEL_${channelIndex}_REQUEST_MODES`
|
| 847 |
+
);
|
| 848 |
if (baseUrl) {
|
| 849 |
validateApiBaseUrl(baseUrl, {
|
| 850 |
allowedPlainHttpBaseUrls: readPlainHttpApiBaseUrlAllowlist(env.OPENAI_ALLOWED_PLAIN_HTTP_API_BASE_URLS)
|
|
|
|
| 868 |
...(upstreamHeaders ? { upstreamHeaders } : {}),
|
| 869 |
...(providerManifest ? { providerManifest: createProviderManifestSummary(providerManifest) } : {}),
|
| 870 |
...(providerProfile ? { providerProfile } : {}),
|
| 871 |
+
...(failureCooldownMs ? { failureCooldownMs } : {}),
|
| 872 |
+
...(requestModes ? { requestModes } : {})
|
| 873 |
}));
|
| 874 |
}
|
| 875 |
|
src/lib/image-route-support.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
| 6 |
isChannelFailure,
|
| 7 |
isCredentialFailure
|
| 8 |
} from './channel-router';
|
|
|
|
| 9 |
import { RequestValidationError } from './image-request-utils';
|
| 10 |
import type { ImageGenerationBackend } from './image-upstream-strategy';
|
| 11 |
import { getServerChannelState } from './server-channel-router';
|
|
@@ -44,12 +45,19 @@ export function assertResponsesImageBackendAllowed(input: { imageBackend: ImageB
|
|
| 44 |
}
|
| 45 |
}
|
| 46 |
|
| 47 |
-
export function reportServerCredentialFailure(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
const serverChannelRouter = getServerChannelState().router;
|
| 49 |
if (!credential || !serverChannelRouter) return;
|
| 50 |
if (isChannelFailure(error)) {
|
| 51 |
-
const reason =
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
appLogger.warn(
|
| 54 |
report.cooldownApplied
|
| 55 |
? `暂时冷却 API 渠道:${credential.channelId}`
|
|
@@ -59,8 +67,11 @@ export function reportServerCredentialFailure(credential: ChannelCredential | un
|
|
| 59 |
return;
|
| 60 |
}
|
| 61 |
if (isCredentialFailure(error)) {
|
| 62 |
-
const reason =
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 64 |
appLogger.warn(
|
| 65 |
report.cooldownApplied
|
| 66 |
? `暂时冷却 API 渠道凭证:${credential.channelId}/${credential.id}`
|
|
|
|
| 6 |
isChannelFailure,
|
| 7 |
isCredentialFailure
|
| 8 |
} from './channel-router';
|
| 9 |
+
import type { ChannelRequestMode } from './channel-request-mode';
|
| 10 |
import { RequestValidationError } from './image-request-utils';
|
| 11 |
import type { ImageGenerationBackend } from './image-upstream-strategy';
|
| 12 |
import { getServerChannelState } from './server-channel-router';
|
|
|
|
| 45 |
}
|
| 46 |
}
|
| 47 |
|
| 48 |
+
export function reportServerCredentialFailure(
|
| 49 |
+
credential: ChannelCredential | undefined,
|
| 50 |
+
error: unknown,
|
| 51 |
+
requestMode?: ChannelRequestMode
|
| 52 |
+
) {
|
| 53 |
const serverChannelRouter = getServerChannelState().router;
|
| 54 |
if (!credential || !serverChannelRouter) return;
|
| 55 |
if (isChannelFailure(error)) {
|
| 56 |
+
const reason = {
|
| 57 |
+
...describeChannelFailure(error, 'channel'),
|
| 58 |
+
...(requestMode ? { requestMode } : {})
|
| 59 |
+
};
|
| 60 |
+
const report = serverChannelRouter.reportFailure(credential, { scope: 'channel', requestMode, reason });
|
| 61 |
appLogger.warn(
|
| 62 |
report.cooldownApplied
|
| 63 |
? `暂时冷却 API 渠道:${credential.channelId}`
|
|
|
|
| 67 |
return;
|
| 68 |
}
|
| 69 |
if (isCredentialFailure(error)) {
|
| 70 |
+
const reason = {
|
| 71 |
+
...describeChannelFailure(error, 'credential'),
|
| 72 |
+
...(requestMode ? { requestMode } : {})
|
| 73 |
+
};
|
| 74 |
+
const report = serverChannelRouter.reportFailure(credential, { requestMode, reason });
|
| 75 |
appLogger.warn(
|
| 76 |
report.cooldownApplied
|
| 77 |
? `暂时冷却 API 渠道凭证:${credential.channelId}/${credential.id}`
|