misonL commited on
Commit
f9a28cd
·
verified ·
1 Parent(s): 6dd78ad

Deploy 34544cc to Docker Space

Browse files

Source: MisonL/gpt-image-playground-customer@34544ccd35f4321f92ab73ef5ac36603ee9f7720

.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,不触发生图费用;MAX_PER_TICK 用于限制探测流量。
 
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、部署目标Agent 摘要。 |
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": "3203c24f5434c569bbfec2fb425430d3e45fcbc3",
4
- "created_at": "2026-06-23T13:55:49.360Z",
5
- "deploy_id": "dea5a9f7-24bf-4297-a091-f730901c220a"
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('uses the server orchestration endpoint for default billable generate requests', async () => {
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(JSON.stringify(agentGenerateCapabilities()));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: { channel_id: 'channel-a' }
 
 
 
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 === 'responses_page_sse_generate_1k' && check.skipped === true)
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 mock smoke passed');
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
- { id: 'gpt2image-responses-sse', prefix: 'IMAGE_REAL_SMOKE_GPT2IMAGE', requiresResponsesModel: true },
22
- { id: 'matsca-images-sse', prefix: 'IMAGE_REAL_SMOKE_MATSCA' }
 
 
 
 
 
23
  ];
24
  const IMAGE_UPSTREAM_FINAL_GATE_COMMAND =
25
- 'npm run smoke:image-upstream-real -- --env-file-if-exists .env.real-smoke.local --require-independent-targets --allow-billable';
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.real_smoke_checks` | `agent:doctor` | 真实 smoke 状态汇总,包含 `agent_generate_1k`、`responses_page_sse_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
 
 
 
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.real_smoke_checks` | `agent:doctor` | 真实 smoke 状态汇总,包含 `agent_generate_1k`、`responses_page_sse_generate_1k`、`agent_edit_1k` 和 `page_sse_edit_2k`。 |
 
 
 
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":"3203c24f5434c569bbfec2fb425430d3e45fcbc3","created_at":"2026-06-23T13:55:49.360Z","deploy_id":"dea5a9f7-24bf-4297-a091-f730901c220a"} as const;
4
 
5
  export const dynamic = 'force-dynamic';
6
 
 
1
  import { NextResponse } from 'next/server';
2
 
3
+ const deployMarker = {"schema_version":1,"local_sha":"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
- imageFormRequest({
747
- apiBaseUrl: 'http://127.0.0.1:1/v1',
748
- apiKey: 'test-key',
749
- stream: false,
750
- streamMode: 'non_stream',
751
- imageBackend: 'responses'
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
- imageFormRequest({
765
- apiBaseUrl: 'http://127.0.0.1:1/v1',
766
- apiKey: 'test-key',
767
- stream: false,
768
- streamMode: 'non_stream',
769
- imageBackend: 'responses'
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
- imageFormRequest({
785
- apiBaseUrl: 'http://127.0.0.1:1/v1',
786
- apiKey: 'test-key',
787
- stream: false,
788
- streamMode: 'non_stream',
789
- imageBackend: 'responses',
790
- n: '2'
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
- imageFormRequest({
803
- apiBaseUrl: 'http://127.0.0.1:1/v1',
804
- apiKey: 'test-key',
805
- stream: false,
806
- streamMode: 'non_stream',
807
- imageBackend: 'responses',
808
- n: '2',
809
- mode: 'edit'
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(upstreamJson.input?.[0]?.content?.some((item) => item.type === 'input_image'), true);
 
 
 
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
- selectedServerCredential = requestApiKey
256
- ? undefined
257
- : serverChannelRouter?.select({ affinityKey: readAffinityKey(request.headers) });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- requestLogContext
 
 
 
 
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<string, { enabled: boolean; recommendedConcurrency?: number }>;
 
 
 
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((defaultBody.upstreamProfile.activeConstraints as { upload: { maxImages: number } }).upload.maxImages, 10);
 
 
 
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((matscaBody.upstreamProfile.activeConstraints as { upload: { maxImages: number } }).upload.maxImages, 8);
 
 
 
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((mixedBody.upstreamProfile.activeConstraints as { upload: { maxImages: number } }).upload.maxImages, 8);
 
 
 
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 { AgentApiError, normalizeAgentError, storedAgentErrorResponse, type AgentErrorBody } from './api-error-response';
 
 
 
 
 
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 { collectOpenAiImagesFromStream } from './image-stream-collector';
 
 
 
 
 
 
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 = options.preparation?.credentialContext ?? prepareAgentGenerate(options.request, options.headers).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(credentialContext.selectedCredential, error);
 
 
 
 
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 (request.partial_images < upstreamProfile.partialImages.min || request.partial_images > upstreamProfile.partialImages.max) {
 
 
 
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(credentialContext?.selectedCredential, error);
 
 
 
 
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 selectedCredential = serverChannelRouter?.select({ affinityKey: readAffinityKey(headers) });
 
 
 
 
 
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
- retry_after_ms: failureReport.retryAfterMs,
959
- cooldown_until: isoDate(new Date(failureReport.cooldownUntil)),
960
- cooldown_target: {
961
- channel_id: failureReport.target.channelId,
962
- ...(failureReport.target.credentialId ? { credential_id: failureReport.target.credentialId } : {})
963
- },
964
- channel_cooldown_scope: failureReport.scope
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 = describeChannelFailure(error, 'channel');
985
- const report = serverChannelRouter.reportFailure(credential, { scope: 'channel', reason });
 
 
 
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 = describeChannelFailure(error, 'credential');
996
- const report = serverChannelRouter.reportFailure(credential, { reason });
 
 
 
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, { channel_id: 'channel-a' });
 
 
 
 
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
- ...(diagnostics.cooldown_target ? { cooldown_target: diagnostics.cooldown_target } : {}),
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 probeRequiredCredentialIds.has(credential.id) || probeRequiredChannelIds.has(credential.channelId);
 
 
 
 
 
 
 
 
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 candidates = healthyCredentials();
 
 
 
216
  if (candidates.length === 0) {
217
- throw new RequestValidationError('当前没有可用的健康渠道凭证。', 503);
 
 
 
 
 
218
  }
219
 
220
  if (options.strategy === 'round_robin') {
221
- const credential = selectRoundRobinHealthy(options.credentials, nextIndex, isHealthy);
 
 
 
 
 
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 (isHealthy(credential)) {
236
  return credential;
237
  }
238
  }
239
 
240
- throw new RequestValidationError('当前没有可用的健康渠道凭证。', 503);
 
 
 
 
 
241
  },
242
  reportFailure(credential: ChannelCredential, reportOptions = {}) {
243
  const currentTime = now();
244
  const scope = reportOptions.scope === 'channel' ? 'channel' : 'credential';
245
- lastFailure = reportOptions.reason ?? { at: currentTime, scope };
246
- const cooldown = setCooldown(credential, scope);
 
 
 
 
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 = reason ?? {
329
- at: now(),
330
- scope: candidate.scope
 
 
 
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: probeRequiredCredentialIds.size,
347
- pendingRecoveryProbeChannelCount: probeRequiredChannelIds.size,
 
 
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('当前没有可用的健康渠道凭证。', 503);
 
 
 
 
 
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(credential: ChannelCredential | undefined, error: unknown) {
 
 
 
 
48
  const serverChannelRouter = getServerChannelState().router;
49
  if (!credential || !serverChannelRouter) return;
50
  if (isChannelFailure(error)) {
51
- const reason = describeChannelFailure(error, 'channel');
52
- const report = serverChannelRouter.reportFailure(credential, { scope: 'channel', reason });
 
 
 
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 = describeChannelFailure(error, 'credential');
63
- const report = serverChannelRouter.reportFailure(credential, { reason });
 
 
 
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}`