{% raw %}
# 配置

[English](configuration.md) | [简体中文](configuration.zh-CN.md)

## 入口

Protoflux 将**基础设施**配置和**业务**配置分开管理：

| 来源 | 内容 | 管理方式 |
|------|------|---------|
| `server.toml` | 基础设施配置（server, web_console, cors, streaming, logging, rate_limit, retry, routing） | 手动编辑文件 |
| **数据库**（SQLite/PostgreSQL） | 业务配置（access_keys, upstreams, models, access_key_groups） | Console UI / API |

从 `server.example.toml` 开始配置基础设施。业务配置完全通过 Console 管理。

**存储模式**：
- **SQLite 模式**（默认）：业务配置存储在 SQLite 数据库的结构化分行表（`config_access_key`、`config_upstream`、`config_model`、`config_access_key_group`）中。Console 编辑持久化到数据库。30 秒轮询检测变更并热重载。
- **PostgreSQL 模式**：业务配置存储在数据库中。Console 编辑通过 30 秒轮询在所有实例间共享。`server.toml` 始终提供基础设施配置。

**启动流程**：
1. 加载 `server.toml`（通过 `--config`）→ 基础设施配置
2. 从数据库加载业务配置（SQLite 或 PostgreSQL）
3. 合并 → 完整配置

## 架构设计

Protoflux 提供两种存储模式，共享相同的配置管理设计：

### 设计思想

业务配置（用户、上游端点、模型、用户组）通过 **Web Console**（`/console`）进行管理。Console 提供可视化界面：

- 配置上游端点（协议、基础 URL、API Key、凭据轮换）
- 声明面向客户端的模型并路由到上游
- 管理 access key 访问控制（API Key、access key group、模型 ACL）
- 查看请求统计和系统健康状态

基础设施配置（服务绑定、CORS、日志、流式传输、限流、重试策略）来自 `server.toml`，**不会**被 Console 修改。这种分离确保基础设施设置保持运维可控（版本控制的文件、环境变量），而业务配置在运行时动态管理。

### SQLite 模式（单实例）

- 业务配置存储在本地 SQLite 数据库的结构化分行表（`config_access_key`、`config_upstream`、`config_model`、`config_access_key_group`）中
- 请求统计存储在同一 SQLite 数据库中
- 30 秒轮询循环检测配置变更并热重载
- 适合开发、边缘部署或单节点生产环境

### PostgreSQL 模式（多实例）

- 业务配置和统计数据存储在共享 PostgreSQL 数据库中
- 多个网关实例轮询同一数据库（30 秒间隔）
- 任一实例的配置编辑自动同步到所有实例
- Refinery 迁移在首次启动时自动创建所需表
- 适合容器化部署和水平扩展

### Redis 分布式状态

当配置 `redis_url` 时，网关的运行时状态通过 Redis 跨实例共享：

- **Session**：用户在实例 A 登录，切换到实例 B 时保持登录状态
- **Rate Limit**：分布式限流，用户无法通过轮询多实例绕过限制
- **Log Broadcast**：Console SSE 日志流汇聚所有实例的请求日志
- **IP Ban**：控制台暴力破解封禁在所有实例间同步

Redis 仅负责运行时状态，业务配置（access_keys/upstreams/models）仍通过 PostgreSQL/SQLite 持久化。

不配置 `redis_url` 时，所有状态为纯内存模式，零额外开销。Redis 连接失败时自动降级到内存模式。

```toml
[server]
redis_url = "redis://redis-host:6379"
redis_key_prefix = "protoflux:"  # 多集群共享同一 Redis 时用不同前缀
```

### 服务器配置

`[server]` 部分控制 HTTP 服务器和存储后端：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `host` | string | `0.0.0.0` | 监听地址 |
| `port` | u16 | `7890` | 监听端口 |
| `max_request_size_mb` | f64 | `10` | 最大请求体大小（MB），范围 0.01–100 |
| `upstream_idle_connections` | u32 | `32` | 每个上游主机的 HTTP 连接池大小 |
| `upstream_user_agent` | string | `"Protoflux"` | 上游请求的 `User-Agent` 头。支持环境变量：`${UPSTREAM_USER_AGENT}` |
| `worker_threads` | u32 | `4` | Tokio 异步运行时工作线程数。适用于 CPU 受限的容器环境（如 0.5 CPU 时设为 2） |
| `storage_mode` | string | `sqlite` | 存储后端：`sqlite` 或 `postgresql` |
| `postgres_url` | string? | 无 | PostgreSQL 连接 URL（`storage_mode = "postgresql"` 时必需） |
| `sqlite_path` | string | `protoflux.sqlite` | SQLite 文件路径（仅 `storage_mode = "sqlite"` 时使用） |
| `redis_url` | string? | 无 | Redis 连接 URL。设置后启用分布式状态共享（session、限流、日志广播、IP 封禁）。仅空白字符的值会被 trim 并视为未配置 |
| `redis_key_prefix` | string | `""` | Redis key 前缀（空字符串时默认使用 `"protoflux:"`）。多个网关集群共享同一 Redis 时用不同前缀区分。仅当 `redis_url` 已配置时生效，空白值会被 trim |
| `log_queue_dir` | string? | 无 | 追加式文件日志队列目录（仅单实例模式）。未设置时使用系统临时目录。Docker 部署建议挂载卷路径 |
| `script_max_operations` | u64 | `10000` | JavaScript每次执行的最大操作数。沙箱引擎以此作为资源上限（无竞态，per-evaluation）。10000 次操作通常 < 5ms，足以防御恶意脚本。合法复杂脚本若触限可适当增大 |
| `metrics_auth_token` | string? | 无 | `/metrics` 端点的 Bearer Token 认证令牌。设置后，请求必须包含 `Authorization: Bearer <token>` 头。**安全**：当此字段为空或未配置时，端点默认禁用（返回 403） |
| `request_timeout_secs` | u64 | `120` | 全局请求超时时间（秒）。超过此时长的请求将被终止并返回 408 Request Timeout。**注意**：SSE 流式请求在流建立后不受此超时限制 |
| `graceful_shutdown_timeout_secs` | u64 | `30` | 优雅关闭期间等待进行中请求完成的最大时间。超时后，即使仍有请求活跃，服务器也会强制终止 |
| `pre_stop_delay_secs` | u64 | `15` | 收到 SIGTERM 后启动优雅关闭前的延迟时间。在 Kubernetes 部署中，这允许在服务器停止接受新连接前，等待 Pod 从 Service endpoints 中摘除。设为 0 则禁用 |
| `ip_rate_limit_rpm` | u64? | 无 | 每 IP 限速（每分钟请求数）。设置后，每个客户端 IP 使用滑动窗口算法限制为此数量的请求/分钟。IP 从 `X-Forwarded-For` 头（第一个 IP）或连接信息中提取。作为每用户限速的补充 |
| `trusted_proxies` | string? | 无 | 受信任代理的 CIDR 列表（逗号分隔，如 `"10.0.0.0/8,172.16.0.0/12"`）。配置后，会解析 `X-Forwarded-For` 头以提取真实客户端 IP。未设置时，仅使用直连 IP 进行限速 |
| `upstream_read_timeout_secs` | u64 | `360` | 等待上游连续数据块之间的最大时间（适用于初始响应和后续每次流式读取）。必须大于 `stream_idle_timeout_secs`，以确保网关层的空闲超时先触发。对于扩展思考模型（如推理模型可能在 token 之间暂停 5 分钟以上），可适当增大 |
| `upstream_total_timeout_secs` | u64 | `3600` | 上游请求的最大总时间，包括响应体读取。对于 SSE 流，`read_timeout` 在每个 chunk 时重置，而 `total_timeout` 是绝对上限。防止极端长流占用资源 |
| `tcp_keepalive_secs` | u64 | `50` | 上游连接的 TCP keepalive 间隔（秒）。控制操作系统发送 keepalive 探测包的频率。较短的值可以更快检测到僵尸连接（被 NAT/负载均衡器静默丢弃的连接）。跨境部署建议设为 10-15s；同区域部署使用默认 50s 即可 |
| `postgres_pool_size` | u32 | `16` | PostgreSQL 最大并发连接数。仅当 `storage_mode = "postgresql"` 时生效 |
| `postgres_wait_timeout_secs` | u64 | `10` | deadpool 连接池获取超时秒数。慢查询占满连接池时，超过此值则快速失败返回错误，而非让请求无限挂起，防止单个慢查询级联阻塞所有 DB 相关控制台页面。0 = 不设超时（无限等待，不推荐）。仅当 `storage_mode = "postgresql"` 时生效 |
| `postgres_statement_timeout_secs` | u64 | `30` | PostgreSQL 单条语句超时秒数（仅作用于 stats 池）。通过 `SET statement_timeout` 限制单条 SQL 执行时长，防止大范围聚合查询（如 12h 时间序列扫描）无限占用连接、饱和 RDS。0 = 不设超时。仅当 `storage_mode = "postgresql"` 时生效 |
| `script_pool_size` | usize? | `4` | 并发脚本执行槽位数。脚本引擎维护独立 QuickJS 上下文池，每个槽位约占 10MB 内存。如果性能分析显示高负载下存在互斥竞争，可适当增大 |
| `max_response_body_mb` | u32 | `25` | 非流式响应的最大上游响应体大小（MB，缓冲在内存中）。流式响应不受此限制——按 chunk 转发。对于大型非流式响应（如 base64 图片、大型 embeddings），可适当增大 |
| `max_global_concurrency` | u32? | `1000` | 所有代理路由的最大并发请求数。达到限制时，新请求返回 HTTP 503。防止高负载下资源耗尽。设为 `None` 或 `0` 则禁用 |
| `encryption_key` | string? | 无 | 静态敏感数据加密密钥（access keys、上游 API keys）。AES-256-GCM 密钥，64 字符十六进制格式，或密码短语（通过 SHA-256 派生）。通常通过 `${ENCRYPTION_KEY}` 环境变量设置 |
| `encryption_key_deprecated` | string[] | `[]` | 已弃用的加密密钥，**仅用于密钥轮换期间的解密**。使用任何已弃用密钥加密的数据仍可解密，但所有新写入使用当前活跃的 `encryption_key`。支持通过 `${ENCRYPTION_KEY_DEPRECATED}` 设置逗号分隔的十六进制字符串 |
| `request_log_capacity` | usize | `1000` | 请求日志环形缓冲区容量。内存中保留的最近请求/响应条目数，通过 `/console/api/requests` 暴露。缓冲区满时最旧条目被覆盖。每个条目约 1KB |
| `histogram_buckets` | f64[]? | 无 | 自定义 Prometheus 直方桶边界（单位：秒），用于请求延迟指标。内置桶：`[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0, 30.0]`。可根据不同延迟特征的工作负载自定义（如推理模型可添加 60.0/120.0/300.0）。必须升序排列 |
| `ebp_cooloff_secs` | u64 | `30` | EBP 拥塞控制冷却期（秒）。在乘法减小（探测上限减半）之后，控制器进入冷却期，抑制加法增大。增大此值可使拥塞事件后的恢复更保守；减小此值可在稳定上游上更快恢复 |
| `sidecar_ready_timeout_secs` | u64 | `10` | 插件 sidecar gRPC 服务器就绪的最大等待时间（秒）。每个 sidecar 使用指数退避探测（初始 100ms，最大 2s）。对于启动较慢的插件可适当增大 |
| `memory_soft_limit_mb` | usize | `0` | 内存上限（MB，ADR-005）——最大内存使用量 = Critical 硬拒线。水位线为该 cap 的 70/80/90/100%（Warning/Pressure/Reclaim/Critical）；请求门控（落盘 + 排队）从 Warning（70%）起激活，到达 cap（100%）时以 429 硬拒。`0` = 自动探测：cgroup v2/v1 → ECS Container Metadata API（Fargate）→ `/proc/meminfo`（Linux 裸机/VM）→ `sysctl hw.memsize`（macOS），使用 `limit - headroom`（headroom = clamp(15% × limit, 128MB, 512MB)）。手动设置可覆盖自动探测 |
| `memory_queue_timeout_secs` | u64 | `300` | 请求在内存压力队列中的最大等待时间（秒），超时后返回 429（ADR-005）。默认 300s 与排队语义一致（用户感知排队时长 ≥ 网关排队超时，避免过早 timeout 违背排队语义） |
| `memory_queue_max_depth` | u32 | `0` | 慢路径 permit-wait 队列同时驻留请求数上限。超限则新请求立即 429 + `Retry-After`（reason `queue_full`），不等待 `memory_queue_timeout_secs`。界死循环 backlog 使突发在有限时间排空，避免 429 风暴 + 499 客户端断开。`0` = 禁用（legacy：排队至超时）。按可容忍 backlog 与 drain 速率标定，建议起点 ~`handler_permits_ceiling`（一轮在途容量排队）；持续过载靠 ECS 按 `protoflux_admission_reject_reasons_total{reason="queue_full"}` 扩容（ADR-005） |
| `spill_write_concurrency` | usize | `4` | **Producer**: 并发 spill 写文件数（spawn_blocking slots），每 slot ~2.3MB。不支持热重载（ADR-005） |
| `memory_consume_max_concurrent` | usize | `100` | **Consumer**: Warning/Pressure/Reclaim 回灌速率（从 spill 文件读回内存的并发数）。必须 > 0；启动时验证。受 shared budget cap（ADR-005） |
| `memory_consume_critical_max_concurrent` | usize | `2` | **Consumer**: Critical 回灌速率。内存耗尽时仅允许少量 body 回灌。受 shared budget cap（ADR-005） |
| `memory_admission_enabled` | bool | `true` | 启用前馈准入（ADR-005）：请求放行前按「实时 RSS + in-flight 已放行 body 预算 + 本请求 Content-Length」逐请求决策——超 admit 线则落盘排队，body 不进内存，堵死 Normal 零开销直通在 watchdog 下一 tick 前的突发盲区。前馈层只 admit / 落盘排队，不抢先拒；429 仍由慢路径（队列满 / spill 配额 / 超时）兜底。`false` = 回退旧反馈式（Normal 直通） |
| `memory_admission_threshold_pct` | u8 | `70` | 前馈 admit 线 = cap × pct / 100。`rss + content_length > admit 线` 时落盘排队。默认 70% 与 Warning 水位对齐，前馈与反馈 watchdog 在同一 RSS 触发。范围 1..=100（启动校验）；调小 = 更保守（更早 spill） |
| `memory_admission_fresh_read_pct` | u8 | `70` | 接近 admit 线时同步重读 RSS 的阈值。`last_memory_usage_bytes` 快照最多 ~1s 陈旧（一个 watchdog tick）；当 `rss > pct×cap` 或投影已超 60%cap 时，决策路径调用 `read_usage()` 刷新 RSS 再算投影，避免 tick 中段上升的 RSS 被低估。范围 1..=100 |
| `memory_force_spill_body_mb` | usize | `0` | 强制落盘阈值（MB，`0` = 禁用）。body 超此大小无条件落盘排队，不受 RSS 投影影响，兜底一次性 `consume` 读回的 per-request 内存成本。非 0 时必须 ≤ `max_request_size_mb`（启动校验） |
| `memory_admission_handler_permit` | usize | `30` | 单 handler worst-case 内存占用（MB，默认 30 = 30MB）。用于派生**自适应** permit 上下界：`handler_permits_initial = soft_budget / (handler_permit × 1MB)`（保守起点）、`handler_permits_ceiling = cgroup_budget / (handler_permit × 1MB)`（AIMD 增加上限，随机器伸缩；无 cgroup 时 = `usize::MAX`）。AIMD target 从 initial 攀向 ceiling——RSS 低于低水位时加法增大、高于高水位时乘性减小，停在运行期实测操作点。覆盖 request body + response buffer + reasoning 内容 + serde 反序列化开销。按实测峰值 RSS / active handlers 标定。必须 > 0（启动校验）。显式设 `memory_admission_handler_permits > 0` 可钉死 initial=ceiling、关闭自适应攀爬 |
| `health_probe_enabled` | bool | `true` | 启用上游主机的 TCP 健康探测（ADR-012）。启用后，后台任务定期通过 TCP 连接探测所有已注册的上游主机，使网关能够区分网络中断和僵尸连接池，实现自动连接池恢复 |
| `health_probe_interval_secs` | u64 | `10` | TCP 健康探测间隔（秒，ADR-012）。较短的间隔可以更快检测网络问题，但会产生更多探测流量 |
| `health_probe_timeout_secs` | u64 | `3` | TCP 健康探测连接超时（秒，ADR-012）。应短于探测间隔以避免探测重叠 |
| `dns_refresh_interval_secs` | u64 | `60` | 健康探测目标的 DNS 重新解析间隔（秒，ADR-012）。处理 CDN 故障切换、IP 变更和 DNS TTL 过期 |

#### 内存准入调优场景

内存准入系统的核心参数相互关联，需要根据实际负载特征调优。以下是常见场景和推荐配置：

**场景 1：高并发小请求（聊天 API、短 prompt）**

特征：请求体 < 10KB，响应体 < 50KB，并发 100+

```toml
memory_admission_handler_permit = 10   # 每个 handler 只需 10MB
memory_admission_threshold_pct = 75    # 允许更高 RSS 利用率
memory_force_spill_body_mb = 0         # 小请求无需强制落盘
```

效果：`budget=300MB` 时 `handler_permits_initial=30`，可支撑更高并发。

**场景 2：大上下文请求（长文档分析、代码审查）**

特征：请求体 100KB-2MB，响应体 50-200KB，并发 10-30

```toml
memory_admission_handler_permit = 80   # reasoning 内容可达 50MB+
memory_admission_threshold_pct = 65    # 更保守，提前 spill
memory_force_spill_body_mb = 5         # 超大请求直接落盘
```

效果：`budget=300MB` 时 `handler_permits_initial=3`，避免 OOM。

**场景 3：多模态请求（图片+文本）**

特征：请求体含 base64 图片（1-10MB），响应体 < 100KB

```toml
memory_admission_handler_permit = 50   # 图片解码 + reasoning 缓冲
memory_force_spill_body_mb = 8         # 大图片请求强制落盘
max_request_size_mb = 20               # 允许更大请求体
```

效果：图片请求自动 spill，避免单请求占满内存。

**场景 4：CPU 受限环境（0.25-0.5 vCPU）**

特征：容器 CPU 配额低，处理速度慢

```toml
worker_threads = 1                     # 匹配 CPU 核数
memory_admission_handler_permit = 30   # 默认值
memory_queue_timeout_secs = 600        # 延长排队超时（CPU 慢，默认 300 不够）
```

效果：避免 handler 长时间占用 permit，排队请求不超时。

**场景 5：内存利用率低但频繁排队**

症状：RSS < 60% cap，但日志显示 `handler_full` 或 `spill`

诊断：
```bash
grep "handler_permits\|handler_full\|AIMD" protoflux.log | tail -20
```

解决：调小 `memory_admission_handler_permit`（如 30→20→15），即增加 `handler_permits_initial` / `handler_permits_ceiling`（二者均 ∝ budget / handler_permit）；若 target 卡低不攀升，检查 AIMD 死锁（`initial < ceiling` 不变式，见 ADR-005）。

**参数关系图**

```
cap (memory_soft_limit_mb)
 ├─ baseline (60MB)
 ├─ spill_overhead (20MB)
 ├─ reserved (50MB + cap/16)
 └─ budget = cap - baseline - spill - reserved
     ├─ handler_permits_initial = soft_budget / handler_permit    （保守起点）
     └─ handler_permits_ceiling = cgroup_budget / handler_permit  （AIMD 增加上限）
         └─ AIMD target 在 [initial, ceiling] 间自适应；active 超出则 spill 排队

admit 线 = cap × threshold_pct%
 ├─ RSS + Content-Length < admit 线 → 直接放行
 └─ RSS + Content-Length > admit 线 → spill 到磁盘，等 handler permit
```

**调优流程**

1. **观察基线**：默认配置运行，监控 RSS 和 handler 并发数
2. **识别瓶颈**：
   - RSS 接近 cap → 调大 `threshold_pct` 或增加 `cap`
   - 频繁 spill 但 RSS 低 → 调小 `handler_permit`
   - 排队超时 → 增加 `queue_timeout` 或增加 CPU
3. **压测验证**：用 `scripts/stress_test.sh` 模拟真实负载
4. **迭代调整**：每次只改一个参数，观察效果

修改后用下面命令校验：

```bash
cargo run -- --config server.example.toml config-validate
```

### 流式传输配置

`[streaming]` 部分控制 SSE 流式连接的行为：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `keepalive_seconds` | u64 | `15` | SSE keep-alive 心跳间隔（秒）。网关按此间隔向客户端发送 `:keep-alive` 注释，防止代理/浏览器超时断开连接。建议设为短于反向代理的空闲超时（如 Nginx 默认 60s，设 15s 即可） |
| `bootstrap_retries` | u32 | `1` | 首个字节前的重试次数。上游连接在发送第一个 SSE 数据块前失败时，网关重试的次数。一旦首个数据块已发送给客户端，不再重试 |
| `stream_log_timeout_secs` | u64 | `3600` | SSE 流式日志后台任务超时时间（秒）。流结束后，后台任务等待上游流完成以记录最终统计。若流异常终止（客户端断开 + sender 丢失），此超时释放僵尸任务。3600 秒（1 小时）远超任何合法 LLM 流式响应，仅覆盖病理场景 |
| `stream_idle_timeout_secs` | u64 | `300` | 上游连续不发数据的最大空闲时间（秒）。超过此时间未发送任何数据块，网关将关闭流并返回空闲超时错误。覆盖首字节等待与流中途停顿（不只首 token）——reasoning 模型长时间思考暂停也可能触发，需调大。防止上游静默停止发送导致的僵尸连接。设为 0 则禁用。须小于 `upstream_read_timeout_secs` 让网关层先触发。环境变量 `STREAM_IDLE_TIMEOUT_SECS` |
| `max_stream_duration_secs` | u64 | `3600` | 单个流式响应的最大总时长（秒）。超过此时长后，无论上游活动如何，网关都会关闭流。防止无限长的流占用资源。设为 0 则禁用 |
| `max_concurrent_streams` | u32? | `200` | 所有客户端的最大并发 SSE 流数。全局信号量限制流式响应。达到限制时，新的流请求返回 HTTP 503。每个 SSE 流会产生一个 Tokio 任务并占用一个上游连接。设为 `None` 或 `0` 则禁用 |

### 日志配置

`[logging]` 部分控制结构化日志和可观测性：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `level` | string | `"info"` | 日志级别：`trace`、`debug`、`info`、`warn`、`error`。控制结构化日志的详细程度 |
| `format` | string | `"json"` | 日志格式：`json`（机器可解析）、`compact`（单行人类可读）、`pretty`（多行带颜色） |
| `max_body_size_mb` | f64 | `25` | 所有日志记录中每个请求体的最大捕获大小（MB）——终端 + SSE/webconsole。超出此大小的请求体会被截断 |
| `log_body_to_terminal` | bool | `false` | 是否将请求/响应体记录到终端输出。为 false 时，请求体仅在 SSE/webconsole 日志中捕获 |
| `otel_endpoint` | string? | 无 | OpenTelemetry OTLP gRPC 端点，用于分布式追踪导出（需要 `--features otel` 构建）。示例：`"http://jaeger:4317"` |
| `otel_service_name` | string | `"protoflux"` | 报告给 OpenTelemetry collector 的服务名称。用于在追踪和 span 中标识此服务 |
| `persist_request_logs` | bool | `true` | 是否将请求日志持久化到数据库。为 false 时，日志事件仍通过 FileLogQueue 流向 SSE 广播，但不写入 DB |
| `log_retention_days` | u32 | `7` | 数据库中请求日志的保留天数。PostgreSQL 通过 DROP PARTITION 清理（DDL，即时），SQLite 通过 DELETE 清理。0 表示禁用自动清理 |
| `stream_body_max_disk_mb` | u64 | `256` | SSE 流式响应体磁盘溢出的最大磁盘空间（MB）。超出后新 chunk 被丢弃并标记 `body_incomplete` |
| `stream_body_batch_size_kb` | usize | `64` | 流式 body writer 的 batch 大小（KB）。攒满后 flush 到磁盘。增大可减少 I/O 次数但增加内存占用 |
| `stream_body_linger_ms` | u64 | `5` | batch 不满时的最长等待时间（毫秒）。减小可降低延迟但增加 I/O 频率 |
| `stream_body_channel_capacity_chunks` | usize | `2048` | 流式 body mpsc channel 容量（**chunks 数，非字节**）。满时新 chunk 被丢弃（非阻塞）。默认 2048 chunks ≈ 2 MB 缓冲（按 1 KB/chunk 估算） |

**OpenTelemetry**：当配置了 `otel_endpoint` 且二进制使用 `--features otel` 构建时，Protoflux 通过 OTLP gRPC 协议导出追踪。这支持跨微服务的分布式追踪。网关会自动将 W3C Trace Context（`traceparent` 头）传播到上游提供商，从而实现端到端的请求追踪。

### 价格配置

`[pricing]` 段控制模型计费的价格解析与价格快照版本化。价格是**追加快照**（append-only），每条带 `created_at` 时间戳；成本按分钟桶时间戳即时算（架构见「价格与计费」段）。价格经**两层** field 级 overlay 解析（低→高）：Tier1 `meta.toml` 基线 → Tier2 远程定价服务（本节配置）；field 级语义——`Some` 覆盖、`None` 保留下层、`Some(0)` 显式免费，`extra_pricing` 是 key 级 map-merge。`PriceResolver` 把解析后的当前生效价以追加快照写入 `config_entity_version`（`entity_type='model_pricing'`、`created_at` 取 DB 服务端时间、`source` 记录胜出层 `tier1:meta.toml` / `tier2:remote`）。单位为 micro-USD per 1M tokens（i64），console 人机单位为 USD/1M tokens（边界 ×1e6/÷1e6 转换）。

console 手动价是**旁路钉住**而非 resolver 层：管理员经 console 新建/编辑某模型价格快照时，行带固定 `source="console:manual"`，resolver 见 `source.starts_with("console:")` 即跳过该模型（不 diff、不 append），手动价权威、不被远程/基线覆写；删掉钉住行后 resolver 才会按 Tier1/Tier2 重新 append。

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `remote_source` | table? | 无 | 远程定价服务配置。未配置则禁用 Tier2 远程拉取，价格仅由 Tier1 meta.toml 基线决定（console 钉住期间不被覆写）；价格 refresh 定时任务仍 spawn 但跳过 fetch/refresh，保持管理任务数稳定（Memory=R7 / Redis=R14）|
| `retention_days` | u64? | 无 | 价格快照历史保留天数。未配置（默认）则不自动清理；配置为 N 时，housekeeping 周期任务滚动删除 `created_at < now - N*86400` 的非末条快照行（**绝不删末条当前生效行、绝不删 6 config 类型行**，批量限流防长事务）。删除旧快照会使该时段 latest-before 回退到更早快照，历史成本会重算变化；仅当预期远程定价高频浮动时启用 |

#### 远程定价服务

`[pricing.remote_source]` 配置 Tier2 远程定价服务拉取。启动时同步拉取一次（失败回退 Tier1 不阻塞启动），之后定时 refresh。远程响应采用 lenient 反序列化：逐条解析，单条坏数据（缺 `model` / 字段类型不合法）跳过并 warn，绝不致整批回退；未知数值维度落入 `extra_pricing` 扩展映射，非数值字段忽略。

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `url` | string | — | 远程定价服务端点。GET 请求，响应体为 JSON 数组，每项含 `model`（必填）+ `input_price`/`output_price`/`cache_read_price`（micro-USD per 1M tokens，数值）+ 可选 `extra_pricing` 嵌套 map + 可选 `upstream` |
| `auth_header` | string? | 无 | 请求鉴权头值（如 `"Bearer <token>"`）。配置后随请求发送 `Authorization` 头 |
| `refresh_interval_secs` | u64 | `300` | 定时拉取间隔（秒）。每次 tick 先拉取远程价更新内存 Tier2 缓存，再触发 `refresh_and_persist` 解析两层并按 diff 决定是否 append 新快照 |
| `timeout_secs` | u64 | `10` | 单次 HTTP 请求超时（秒）|
| `min_rewrite_interval_secs` | u64 | `21600` | Tier2 写入合流窗口（秒，默认 6 小时）。仅对 Timer 触发的写入生效：当当前行 `source` 源自 tier2 且 `now - 当前行.created_at < 此窗口` 时跳过 append（保留旧当前行），窗口到期后下一次 refresh 取远程最新价 append 一个新快照 |

**Tier2 写入合流**：合流窗口（默认 6 小时）下，Tier2 远程价即使每分钟浮动，每 model_key 每天至多 append 4 行快照（窗口到期取最新价一次），写入增长可预测，防止 `config_entity_version` 表膨胀。**Bootstrap 首次拉取（无当前行）与 ConfigReload（console 手动改价/钉住）不受合流限制**，候选即执行。当前行 `source` 非 tier2（上次是 console 手动改价、即钉住）时，Timer 触发即便 diff 有变也执行 append（手动改价后的远程回弹应记录）。refresh 定时任务每 tick 用 `catch_unwind` 包裹，panic/错误记 warn 后等下一 tick 重试，不终止任务。

#### 价格区间与快照时间

快照是**价格数据点**，不是"在某时刻生效"的状态转换：一条快照覆盖 `[本条 created_at, 下一新快照 created_at)`，最早一条向前回填到 `-∞`，最新一条向后开放到 `+∞`；只有一条时它是全时段唯一价格。`created_at` 是快照时间戳，可在 console 新建/编辑时设定（补录过去价、预设未来价、纠正时间），**不是"生效起点"概念**——区间由快照的 `created_at` 排序决定，不由字段显式声明。未来时间（`created_at > now`）的快照在到达前为休眠态，不计为当前价、cost join 不命中历史桶。

#### 配置示例

`[pricing]` 是 infra 顶层段（与 `[server]`/`[console]` 等同级）：

```toml
[pricing]
# 远程定价服务（Tier2）；不配则价格仅由 meta.toml 基线（Tier1）决定
[pricing.remote_source]
url = "https://pricing.example.internal/v1/models"
auth_header = "Bearer <token>"
refresh_interval_secs = 300        # 每 5 分钟拉取一次
timeout_secs = 10
min_rewrite_interval_secs = 21600  # Tier2 写入合流窗口 6h

# 可选：价格快照历史保留（默认不清理）
# retention_days = 365
```

Tier1 基线价写在 `crates/core/src/metadata/meta.toml` 的 `[[models]]` 条目里（编译期嵌入，可经 Console API 覆盖）。价格字段全为 `Option`，field 级 overlay——只填想覆盖的维度，未填保留下层：

```toml
[[models]]
patterns = ["gpt-5*"]
priority = 10
# …（其它元数据字段）…
# 价格（micro-USD per 1M tokens；console 人机单位是 USD/1M tokens，×1e6 转换）
input_price  = 5000000     # $5/1M input tokens
output_price = 15000000    # $15/1M output tokens
cache_read_price = 500000  # $0.5/1M cache-read tokens
# 扩展维度（key 级 map-merge）；cache_creation 价与 input 不同时单独声明
[models.extra_pricing]
cache_creation = 6250000   # $6.25/1M cache-creation tokens
# 按次计费（与 token 价互斥）：per_call 模式只算 per_call_price × 请求数
# billing_mode = "per_call"
# per_call_price = 10000   # $0.01/请求（micro-USD）
# 原币种标签（默认 "USD"，不换算）
# currency = "USD"
```

console 人机单位是 USD/1M tokens（用户输 `5` = $5/1M），后端存储为 micro-USD（`5_000_000`）；console 边界自动 ×1e6/÷1e6 转换。远程定价服务（Tier2）的 JSON 响应里价格也是 micro-USD per 1M tokens（数值）。

### 磁盘配置总览

Protoflux 用多个**独立共享追加日志**（`SegmentedStore`，顺序写 + cursor 驱动段回收）做磁盘缓冲，每个 store 独立目录 + 独立 cap，互不挤占。OOM 防护核心：请求/响应体不驻留内存，落盘后只持 24 字节 `BodyRef` 指针，消费时 mmap 零拷贝读回。下表汇总所有磁盘相关配置项（cap 与 writer 调优），便于一处统览；各字段在所属配置节的详细表中另有说明。

| 字段 | 环境变量 | 默认 | Dockerfile | 存储内容 / 涨满后果 |
|------|----------|------|------------|---------------------|
| `log_queue_max_disk_size_mb` | `LOG_QUEUE_MAX_DISK_SIZE_MB` | `1024` | `1024` | LogEvent NDJSON 队列总盘 cap。涨满 → 新 event 被 drop（日志丢一条，非 body） |
| `stream_body_max_disk_mb` | `LOG_STREAM_BODY_MAX_DISK_MB` | `256` | `1024` | SSE 流式响应体段文件总 cap。涨满 → 新 chunk 丢弃 + 标记 `body_incomplete` |
| `request_body_max_disk_mb` | `LOG_REQUEST_BODY_MAX_DISK_MB` | `256` | `1024` | 脱敏请求体落盘（全流 + drain 持有）。涨满 → spill 失败、body 留内存 inline（OOM 风险） |
| `accumulator_max_disk_mb` | `LOG_ACCUMULATOR_MAX_DISK_MB` | `128` | `256` | 流式累加器（reasoning / text / tool_call 聚合）溢出。涨满 → 降级 inline（per-stream 驻留） |
| `stream_body_batch_size_kb` | `LOG_STREAM_BODY_BATCH_SIZE_KB` | `64` | `64` | writer batch 大小（**非 cap**，调优参数）。攒满 flush 到磁盘；增大降 I/O 次数、增 per-stream 内存 |
| `stream_body_channel_capacity_chunks` | `LOG_STREAM_BODY_CHANNEL_CAPACITY_CHUNKS` | `2048` | `2048` | 流式 body mpsc channel 容量（**chunks 数，非字节**，非 cap）。满时丢 chunk 标 `body_incomplete`；默认 2048 ≈ 2 MB 缓冲 |

**调优指引**：

- 生产 `StoreFull` 告警先看日志 `role` 字段（`request body` / `response body` / 累加器 / 日志队列）定位是哪个 store 涨满，再调大对应 cap 或加快 drain（drain 滞后会钉盘）。
- cap 是**止血缓冲**（给 headroom、延迟涨满），**非真修复**——`completion_tx` 非 EOS 退出不设会导致 extent 钉盘 3600s，cap 调多大最终都会涨满。真修复是 `CompletionOnDrop` 闸门（见改进日志 `body-store-completion-gate-and-cap-tuning`）。
- `log_queue_segment_size_mb`（默认 256）是单段滚动大小（非 cap），与 `log_queue_max_disk_size_mb` 配合：段写满滚动、总量超 cap 触发清理。
- 脱敏契约：`request_body_store` / `response_body_store` / 累加器盘上只存 redacted 体（28 类凭据脱敏在落盘前）；入关请求体落 raw 到 per-request 临时文件（短暂 ephemeral，请求结束 RAII `Drop` 即删，独立目录，**无聚合磁盘 cap**——瞬时占用由在途请求数 × `max_request_size_mb` 界）。

### 观测指标单位分层

Protoflux 的磁盘/内存观测按大厂工业级实践做**单位分层**（关注点分离，非单一规则）：

| 层 | 后缀 | 读者 | 转换点 | 理由 |
|----|------|------|--------|------|
| 采集层（Prometheus metric） | `_bytes` | 机器（scrape） | 无（base unit） | Prometheus 官方 "Use base units" 惯例；`rate()`/`increase()` 按字节数学正确；Grafana `unit: bytes` 自动转人读；社区 dashboard（node_exporter 等）兼容 |
| API 层（/stats JSON + `InstanceMemorySummary` + `LogQueueDiagnostics`） | `_mb` | 人（运维） | 后端输出时 `bytes / 1024 / 1024` | 前端拿到即可读，无需心算；Redis 存的 JSON 也走此层 |
| 代码层（内部 struct 字段 + 跨函数参数） | `bytes` | 工程师（源码） | 无（保精度） | 跨函数传 bytes 避免反复转换误差；转换只在边界（config 加载 / JSON 输出） |
| 显示层（console UI） | `MB`/`GB` | 人（用户） | `formatMB` 在 UI 边缘 | `< 1024` → `N MB`，`≥ 1024` → `X.X GB` |
| channel 容量 | `_chunks` | 全层统一 | 无 | mpsc 消息数，非字节（2048 chunks ≈ 2 MB 按 1 KB/chunk 估算） |

metric 名与 JSON 字段名**解耦但对应**：metric `protoflux_log_queue_disk_used_bytes`（机器 scrape）↔ JSON `log_queue_disk_mb`（人读）。gauge 名带 `_used`（`disk_used_bytes`）而 JSON 字段不带 `_used`（`disk_mb`）——这是有意的语义分层：gauge 强调"已用"以区别于 cap gauge，JSON 字段在 `usage / cap` 配对语境下无需重复"已用"。下表列出主要映射：

| Prometheus gauge（`_bytes`） | /stats JSON 字段（`_mb`） | 说明 |
|----|----|------|
| `protoflux_process_rss_bytes` | `process_rss_mb` | 进程 RSS |
| `protoflux_memory_limit_bytes` | `memory_limit_mb` | cgroup 内存上限 |
| `protoflux_log_queue_disk_used_bytes` | `log_queue_disk_mb` | 日志队列磁盘占用 |
| `protoflux_response_body_store_disk_used_bytes` | `response_body_store_disk_mb` | 响应体 store 占用 |
| `protoflux_request_body_store_disk_used_bytes` | `request_body_store_disk_mb` | 请求体 store 占用 |
| `protoflux_accumulator_store_disk_used_bytes` | `accumulator_store_disk_mb` | 累加器 store 占用 |
| `protoflux_ingress_spill_disk_used_bytes` | `ingress_spill_disk_mb` | 临时 body 占用（per-request 临时文件瞬时占用） |

Redis 多实例模式下，`/stats` 返回 `instances[]`（每实例一个 `InstanceMemorySummary`，含上表各 `*_mb` 字段 + `body_store_config`），集群级聚合用 `total_rss_mb` / `total_limit_mb`。

### 磁盘观测指标

`/stats` JSON 的 `memory` 对象输出以下字段（Memory 模式直接 inline，Redis 模式从 `instances[]` 聚合）：

| 字段 | 单位 | 含义 |
|----|----|------|
| `process_rss_mb` | MB | 进程 RSS（Prometheus gauge `process_rss_bytes / 1024 / 1024`） |
| `memory_limit_mb` | MB | cgroup 内存上限（`memory_limit_bytes / 1024 / 1024`） |
| `log_queue_disk_mb` | MB | 日志队列磁盘占用 |
| `response_body_store_disk_mb` | MB | 响应体 store 磁盘占用 |
| `request_body_store_disk_mb` | MB | 请求体 store 磁盘占用 |
| `accumulator_store_disk_mb` | MB | 累加器 store 磁盘占用 |
| `ingress_spill_disk_mb` | MB | 临时 body 磁盘占用（per-request 临时文件，瞬时，无 cap） |
| `body_store_config` | — | 三个 drain-persistent store 的 resolved cap 配置（见下） |
| `log_queues[].write_pos_mb` | MB（偏移） | 写入偏移位置，**非磁盘占用** |
| `log_queues[].disk_usage_mb` | MB | 当前磁盘占用 |
| `log_queues[].max_disk_size_mb` | MB | 队列总盘 cap |
| `log_queues[].segment_size_mb` | MB | 单段滚动大小（默认 256 MB，可配置，非 cap） |
| `log_queues[].segment_count` | 段数 | 当前活跃段数（无单位） |
| `log_queues[].retention_floor_mb` | MB（偏移） | 回收水位（DbDrain + 非 stale SSE 最小 cursor），**非占用** |
| `log_queues[].retention_floor_no_stale_mb` | MB（偏移） | 忽略 stale 消费者的回收水位（3× 段大小落后视为 stale） |
| `log_queues[].db_drain_cursor_mb` | MB（偏移） | DB drain 消费者 cursor（无 DbDrain 时为 null） |
| `log_queues[].sse_consumer_count` | 个数 | 活跃 SSE 消费者数（无单位） |
| `log_queues[].sse_min_cursor_mb` | MB（偏移） | SSE 消费者最小 cursor（无 SSE 时为 null） |

**偏移字段说明**：`write_pos_mb` / `retention_floor_mb` / `retention_floor_no_stale_mb` / `db_drain_cursor_mb` / `sse_min_cursor_mb` 是**位置偏移**（u64 字节 / 1024 / 1024 截断到 1 MB 精度），非磁盘占用——它们定位"写到哪 / 消费到哪"以驱动段回收，不直接反映盘上字节数。判断盘满看 `disk_usage_mb / max_disk_size_mb`。

`body_store_config` 字段（三个 drain-persistent store 的 resolved cap，前端展示 usage/cap 比例用；入关临时 body 无 cap，见下文「临时 body 存储」）：

| 字段 | 单位 | 含义 |
|----|----|------|
| `stream_body_max_disk_mb` | MB | 响应体 store cap（`stream_body_max_disk_mb` resolved） |
| `request_body_max_disk_mb` | MB | 请求体 store cap |
| `accumulator_max_disk_mb` | MB | 累加器 store cap |
| `body_segment_size_mb` | MB | 三个 body store 共享的段滚动大小（64 MB 硬编码，非配置） |
| `stream_body_channel_capacity_chunks` | chunks | 流式 body mpsc 容量（消息数，非字节） |

### 前端内存指标详解

OverviewPage 实例卡片 + 内存 hover popover 展示的每个指标的含义与诊断用法。字段定义见上表，本节按 hover 布局逐项说明怎么读、异常意味什么、如何处理。

**实例卡片（不 hover 可见）**：

- **水位标签**（`watermark_level`）：Normal → Warning → Pressure → Reclaim → Critical 五档，由 watchdog 每 1s tick 按 RSS 占 `memory_limit` 百分比分类（70/80/90/100% 触发阈值）。Normal = 健康；≥ Pressure（橙）= RSS 逼近 cap，AIMD 开始压 handler/stream permit；Critical（红 + ⚠）= 触发逃生通道拒新请求保进程存活。持续 ≥ Pressure 需排查内存泄漏或调大 cgroup/`memory_soft_limit_mb`。
- **RSS / cap**（`process_rss_mb / memory_limit_mb`）：进程驻留内存 / cgroup 上限。RSS 持续涨不回落 = 泄漏（看 moka capacity / 累加器 inline 降级）；RSS 接近 cap = OOM 风险，看水位标签。Redis 模式顶层 `total_rss_mb / total_limit_mb` 为所有实例之和。
- **磁盘汇总**（`diskUsage`）：五个磁盘占用之和（log_queue + response + request + accumulator + 临时 body），hover 看分项定位哪个 store 涨满。

**hover popover — log_queues 明细**（每个 queue 一块；Memory 模式 1 个 queue，Redis 模式 drain + live 双 queue）：

- **占用 / cap**（`disk_usage_mb / max_disk_size_mb`）：判断盘满看这个。占用接近 cap = 消费跟不上，StoreFull 即将丢日志/429。
- **写偏移**（`write_pos_mb`）：append-log 写到哪了（位置偏移**非占用**）。`write_pos − retention_floor` 的差 = 待回收的积压量。
- **段数 × 段大小**（`segment_count × segment_size_mb`）：活跃段数 × 单段滚动大小。段数持续涨 = 消费跟不上写，段不断滚动不回收。
- **保留水位**（`retention_floor_mb`）：最慢消费者读到的位置（含 stale）。低于它的段可删。`floor ≪ write_pos` = 消费落后，磁盘积压。
- **忽略 stale 的水位**（`retention_floor_no_stale_mb`）：忽略落后 >3 段的 stale SSE 后的水位。**与 `retention_floor_mb` 差大 = 有 stale SSE 客户端钉盘**（断连但 registry 未清理），hover 高亮提示。实际 cleanup 用这个值，stale 消费者不阻止回收。
- **DB drain cursor**（`db_drain_cursor_mb`）：DbDrain 持久化消费到哪了。`null` = 无 DbDrain（Memory 模式无 DB 持久化时）。cursor 不前进 = drain 卡住，日志只落盘不入库。
- **SSE 消费者数 + 最小 cursor**（`sse_consumer_count` / `sse_min_cursor_mb`）：活跃 console 实时日志连接数 + 最慢那个的 cursor。`count > 0` 但 `sse_min_cursor ≪ write_pos` = console 客户端读不动；`count = 0` = 无实时订阅。

**hover popover — 三块 drain-persistent body store**（每块显示 usage / cap，算占用比例）：

- **响应体 store**（`response_body_store_disk_mb / stream_body_max_disk_mb`）：SSE 流式响应体落盘。涨满 = 流式 body chunk 丢弃 + 标 `body_incomplete`（响应体不完整）。常见原因：客户端断连但 `completion_tx` 非 EOS 退出钉盘 3600s（见磁盘 store 释放机制）。
- **请求体 store**（`request_body_store_disk_mb / request_body_max_disk_mb`）：脱敏请求体落盘（供审计/重放）。涨满 = spill 失败 body 留内存 inline（OOM 风险）。常见原因：drain 滞后（DB 持久化慢）。
- **累加器 store**（`accumulator_store_disk_mb / accumulator_max_disk_mb`）：流式 reasoning / text / tool_call 聚合溢出。涨满 = 降级 inline（per-stream 驻留，OOM 风险）。常见原因：长 thinking 流 + 高并发。

**hover popover — 临时 body 存储**（独立一块，只显示瞬时占用，**无 cap**）：

- **临时 body 存储**（`ingress_spill_disk_mb`）：入关请求体流式落盘到 per-request 临时文件（raw 未脱敏，见下「临时 body 存储」架构）。瞬时占用由在途请求数 × `max_request_size_mb` 界，每个文件请求结束 RAII `Drop` 即删。**无聚合 cap、无 429 背压**——这是 5→1 重构根除共享配额计数器的结果：旧模型按 Content-Length 在 admission 预留配额、跨 `接入→上传→TTFB→首字节` 持有，TTFB（模型思考）窗口重叠打满 240MB 瞬时 cap 致 429 风暴；新模型 bytes 在各自文件里、Drop 即删，无共享计数器持有窗口。

**诊断口诀**：盘满看 `disk_usage / max`；积压看 `write_pos − retention_floor`；stale 钉盘看 `retention_floor` 与 `retention_floor_no_stale` 差；drain 卡看 `db_drain_cursor` 不前进；SSE 慢看 `sse_min_cursor ≪ write_pos`；OOM 风险看水位 ≥ Pressure + RSS 接近 cap。

### 磁盘 store 释放机制

四个磁盘 store 各有独立目录 + cap + 释放闸（入关临时 body 是 per-request 文件，无 cap、RAII 删除，见下「临时 body 存储」）。OOM 防护核心：body 不驻留内存，落盘后只持 24 字节 `BodyRef` 指针，消费时 mmap 零拷贝读回；段回收由消费 cursor 驱动（消费跟上即清到活跃段，消费落后即保留到 cursor，stale 消费者 3× 段大小落后被忽略以免钉盘）。

| Store | 目录 | cap 配置 | 释放闸 |
|----|----|----|----|
| `response_body_store` | `body_segments/` | `stream_body_max_disk_mb` | `StreamEndOnDrop`（flush writer）+ aggregator abandoned-receiver release（publish 10s 超时放弃时兜底）+ orphan sweep + publish 前 resolve-release |
| `request_body_store` | `request_bodies/` | `request_body_max_disk_mb` | drain-cursor reclaim（DB 持久化后回收） |
| `accumulator_store` | `accumulator/` | `accumulator_max_disk_mb` | `SpillingAccumulator::Drop` + `.done()` read-back |
| `log_queue` | `log_queue_dir` | `log_queue_max_disk_size_mb` | retention_floor reclaim（drain + live 双 queue，消费 cursor 驱动） |

`log_queue` 在 Redis 多实例模式下拆 drain + live 双 queue：drain queue（本地消费 → DB 持久化）+ live queue（所有实例订阅 → SSE 广播）。`compute_cleanup_min_consumed` 用 `retention_floor_ignoring_stale` 回收到消费水位，无消费者时 floor = `u64::MAX` 全量回收（仅留活跃段）。

**临时 body 存储架构**（per-request 临时文件，无 cap）：每个入关请求体流式落盘到它**自己**的临时文件（body frame → mpsc capacity=4 → `spawn_blocking` → `TempBodyWriter` 顺序写 flat 文件），峰值内存 = 单帧（~8-64 KB）与 body 大小解耦；handler 拿到的是 `IngressBodyRef`（`Arc<IngressBodyState>`，持 `Arc<TempBodyFile>` + size）。读回两条路：小 body 经 `mmap_body` 零拷贝页映射读回（page-cache 可回收），大 body（图片 base64 等大串）经 `reader()` 64 KiB pread 滑窗读回（bounded RSS，与 body 大小解耦，见架构「请求体流式解析与发送（混合 BlobRef）」）。每流驻留从 body 大小降到算法常数（100 并发 × 1.3 MB 突发 = 130 MB → ~2.4 KB）。

**无聚合磁盘 cap**——这是 5→1 重构根除 429 风暴的核心。旧模型（共享 append-log + CAS 配额计数器）按 Content-Length 在 admission 预留配额、跨 `接入 → 上传 → TTFB → 首字节` 持有，TTFB（模型思考时间）窗口重叠把 ~240MB 瞬时 cap 占满 → admission `StoreFull` → 429 风暴。新模型下每个 body 的 bytes 在**各自的文件**里，无共享计数器、无持有窗口：单 body 上限就是 `max_request_size_mb`（Axum `DefaultBodyLimit` 在 store 之外强制，超限 mid-stream 抛 `LengthLimitError` → 413），瞬时占用由在途请求数 × `max_request_size_mb` 界，每个文件请求结束 RAII `Drop` 即删。无 admission 429（一个 disk-full 变成致命 IO 错误 → 500，罕见且可接受）。

`IngressBodyRef` 是 `Arc` 引用计数 2 个 clone：`request.extensions()`（handler 读）、`response.extensions()`（access_log post phase 读已 consume 的 body 写日志）；最后一个 clone drop 触发 `Arc<TempBodyFile>` 的 `Drop` 删文件，让 access_log 在 `next.run` consume request 后仍能读 body。**取消安全全靠 RAII `Drop`**——无 `CancelGuard` / outstanding-set / shared-records mirror / `release_*` 机器：① `spill_ingress` future 落盘中途被 drop（客户端断连，hyper drop future）时，`tx` drop → writer 的 `blocking_recv` 返 `None` → break，`TempBodyWriter` 的 `Drop` 删部分文件（无共享状态要回滚、无配额 CAS 要反转）；② 正常 `Drop` → `Arc<TempBodyFile>` 最后 clone drop 删文件；③ orphan sweep——已建成 ref 的 owner 被取消/遗忘致 `Drop` 未跑时，按 `max_stream_duration × 2` 阈值删超龄泄漏文件（合法在途流 age < 阈值永不误扫，SIGBUS 安全）。mid-stream 拒绝：body 超 `max_request_size_mb` → 413（writer `Drop` 删部分文件 + `body_incomplete`）。

与 `request_body_store` 区别：临时 body 存储落 **raw 未脱敏** body（ephemeral，请求结束 RAII 删、重启清空整个目录）；request_body_store 落 **redacted 脱敏** body（drain-persistent，供审计/重放）。metrics-only `current_usage` 计数器仅供前端「临时 body 存储」行展示瞬时占用（被 orphan sweep reconcile 到实际磁盘占用），**不 gate 任何 admission**。mmap'd page cache 算 `memory.current`（OOM killer 视角），但临时文件 Drop 即 unlink，page cache 随 inode 即时回收——读回用 mmap（可回收）或 pread（峰值 RSS ≈ 64 KiB 与 body 大小无关），从不整块 `read_all` 大对象。详见 ADR-005「mmap 落盘的 page-cache 边界」。

### Web Console 配置

`[web_console]` 部分控制管理控制台的行为：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `session_auto_renew` | bool | `true` | 启用滑动窗口 session 自动续期。设为 `true` 时，每次认证 API 调用会在剩余生命周期不足 50% 时将 session TTL 延长回 24 小时。设为 `false` 时，session 固定为 24 小时生命周期，不受活动影响 |

## 关键章节

- `access_keys`：结构化客户端访问控制，包含 access key 级 API key 和可选的分组归属
- `access_key_groups`：access key group 级别的模型访问权限控制（ACL）
- `upstreams`：命名的上游分组，包含协议、base URL 和上游凭证
- `models`：公开模型 ID 到上游名称/上游模型名的映射，支持模型级 `base_url` 覆盖
- `routing`：会话级 upstream 亲和的可选调优（默认自动生效，可按需配置）
- `web_console`：控制面鉴权和管理端 UI 行为
- `cors`：管理面浏览器访问策略

## 支持的协议

| 配置键 | 说明 |
|-------|------|
| `openai` | OpenAI Chat Completions API |
| `openai_response` | OpenAI Response API |
| `openai_images` | OpenAI Images API（图片生成与编辑） |
| `openai_embeddings` | OpenAI Embeddings API |
| `openai_audio` | OpenAI Audio API（transcriptions、translations、speech） |
| `openai_rerank` | OpenAI Rerank API（/v1/rerank、/v2/rerank；Jina/Cohere/vLLM） |
| `anthropic` | Anthropic Messages API |
| `google` | Gemini Generative Language API |
| `aws_converse` | AWS Bedrock Converse API |
| `dashscope` | 阿里云 DashScope（文本、多模态、图像、视频） |

Protoflux 没有独立的 `Upstream` 领域概念。配置中的 `upstreams` 只是对上游进行分组和命名的方式，所有路由和执行都由 `protocol` 驱动。

## Access Key 与分组访问控制

Protoflux 通过 **access_keys** 和 **access_key_groups** 实现细粒度的模型访问控制：

- **`access_keys`** — 每个条目定义一个客户端 API key、可选的名称 `name`、可选的 `groups` 列表（一个 key 可归属多个组），以及 `enabled` 标记。
- **`access_key_groups`** — 每个分组有一个 `name`、`models` 列表和 `load_balancers` 列表，两个维度相互独立。`models` 为空表示没有任何模型访问权限，`models = ["*"]` 允许所有模型；`load_balancers` 同理控制负载均衡器访问（空 = 全部拒绝，`["*"]` = 全部允许，填负载均衡器的 canonical 名）。「允许所有模型」不附带负载均衡器权限。

### 认证解析机制

认证匹配采用**短路链式解析**，优先级如下：

1. `Authorization: Bearer <key>` — 提取 key，尝试匹配 access key
2. `x-api-key` — 提取 key，尝试匹配 access key
3. `x-goog-api-key` — 提取 key，尝试 access key 匹配
4. 匿名兜底 — 如果以上均未匹配到有效 access key，且配置了 `anonymous = true` 且 `enabled = true` 的 access key，则匹配到该匿名 access key
5. 如果以上全部未匹配 — 返回 HTTP 401

**关键行为**：带了**无效** key 的请求会走到匿名兜底分支。也就是说，当匿名 access key 启用时，带无效 key 和不带 key 的请求效果相同——都以匿名 access key 身份通过请求。带了**有效** key 的请求会正常匹配到对应 access key，不会落到匿名分支。

### 启用与匿名 Access Key

每个 access key 有 `enabled` 字段（默认：`true`）。当 `enabled = false` 时，access key 的 API key 会被 HTTP 401 拒绝 — access key 条目保留在配置中但实际被停用。

在 Console 里配置：**访问密钥** → 「Access Keys」页签 → 新建 `former-employee` → API 密钥 `sk-deactivated-key` / 启用（开关，关闭）。

Protoflux 支持一个**匿名用户**（`anonymous = true`），用于匹配没有 `Authorization` 头的请求。适用于公开 API 或开发环境。每个配置只允许一个匿名用户。

在 Console 里配置：**访问密钥** → 「Access Keys」页签 → 新建 `public` → 匿名（开关，勾选；勾选后 API 密钥置空） / 分组（多选）`public-access`。

当用户属于某个分组时，只能访问该分组 `models` 列表中列出的模型，其他模型的请求会返回 HTTP 403。`list_models` 接口也会按分组权限过滤模型列表。没有分组的用户没有任何模型访问权限。

示例：

在 Console 里配置（不用手写 JSON）：

先建两个密钥组（**访问密钥** → 「密钥组」页签 → 新建）：

| 名称 | 模型 |
|------|------|
| `qwen-only` | `qwen3.6-plus`、`qwen3.6-plus-lb` |
| `full-access` | `*`（全部允许） |

再建两个 access key（**访问密钥** → 「Access Keys」页签 → 新建），各归属对应分组：

| 名称 | API 密钥 | 分组 |
|------|---------|------|
| `alice` | `sk-client-key-1` | `qwen-only` |
| `bob` | `sk-client-key-2` | `full-access` |

## 凭证来源

所有凭证均存储在数据库中，通过 Console API 管理。凭证被视为真相来源，在 Console UI 中按只读方式暴露。

## 模型级 base_url 覆盖

默认情况下，路由使用的 `base_url` 来自对应 upstream 的配置。可以在模型条目上设置 `base_url` 来覆盖上游的默认值：

在 Console 里配置：**模型** → 新建 `custom-model` → 上游 `openai` / 上游模型 ID `qwen3.6-plus` / 模型级 base_url `https://custom-proxy.example.com/v1`。

当模型配置了 `base_url` 时，该模型的请求会发送到指定 URL 而非 upstream 的 `base_url`。凭证（API Key）仍然来自 upstream 配置 — 仅改变请求的目标端点。适用于将特定模型路由到独立代理、vLLM 实例或自定义端点，同时共享同一个 upstream 凭证池。

## 模型别名（Aliases）

每个模型可以通过 `aliases` 字段定义替代名称。别名会解析到相同的模型配置，以 O(1) 方式注册。

在 Console 里配置：**模型** → 新建 `kimi-2.6-20250514` → 上游 `anthropic` / 上游模型 ID `kimi-2.6-20250514` / 别名 `kimi-2.6-0711`、`kimi-2.6`。

客户端发送 `model: "kimi-2.6-0711"` 时，会路由到与 `model: "kimi-2.6-20250514"` 相同的 upstream。适用于模型重命名时的向后兼容，或接受不同提供商的命名约定。

**注意**：用户分组 ACL 仅匹配**规范的 `name`**。如果模型的规范名称是 `"kimi-2.6-20250514"` 且别名为 `"kimi-2.6-0711"`，分组中必须列出 `"kimi-2.6-20250514"` — 访问控制不会解析别名。

### 隐藏规范名称（hide_name）

当你希望模型**对客户端隐藏真实名称**时，设置 `hide_name = true`：

在 Console 里配置：**模型** → 新建 `qwen3.6-plus` → 上游 `openai` / 上游模型 ID `qwen-plus` / 别名 `kimi-k2-0711` / 隐藏主名（开关，勾选）。

当 `hide_name = true` 时：

- 客户端**无法**使用 `"qwen3.6-plus"` 直接访问模型 — 请求会返回 `404 Model Not Found`
- 客户端**只能**使用别名 `"kimi-k2-0711"`
- **LoadBalancer 可以引用 `"qwen3.6-plus"`** — 隐藏模型可以作为负载均衡的 entry
- 首页和 `GET /v1/models` 端点只列出 `"kimi-k2-0711"`（不包含隐藏模型的 canonical name）
- **所有内部操作仍使用 `"qwen3.6-plus"`** — 统计、日志、凭证过滤不受影响

适用场景：

- **白标（White-labeling）**：以品牌特定的名称暴露模型，同时隐藏真实的上游模型 ID
- **迁移**：暴露稳定的公开别名，同时变更底层模型名称
- **多租户隔离**：不同租户看到不同的模型名称，即使路由到同一个 upstream
- **负载均衡专用模型**：隐藏底层模型名称，仅通过 LB 名称暴露给客户端

**无别名配置**：`hide_name = true` 时 `aliases` 可以为空。此时模型对外部 API 不可见，但仍可被 LoadBalancer 引用。

## Upstream Inference Profile（模型 ID 覆盖）

`upstream_inference_profile` 字段允许你覆盖发送给上游的模型 ID。这个字段源自 AWS Bedrock 的 [应用推理配置](https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles.html)，但现在可以作为**通用的模型 ID 覆盖**用于任何协议。

设置后，该值会在所有请求中作为上游模型 ID 使用，`upstream_model_id` 则作为备用值。适用场景：

- 上游期望使用 ARN 而非原始模型名（AWS Bedrock Converse）。
- 上游为计费、路由或优惠模型使用不同的标识符（如 `zai.glm-5` 在 OpenAI 兼容端点上使用不同的计费 ID）。
- 需要将公开的模型名与上游标识符解耦。

在 Console 里配置：**模型** → 新建 `glm-5` → 上游 `aws-global` / 上游模型 ID `zai.glm-5` / `upstream_inference_profile` `arn:aws:.../abcd1234`。

> ⚠️ `upstream_inference_profile` 不在本次给出的 Console UI 字段映射中；若 UI 无对应输入项，须直接写入数据库或确认 UI 字段名后再补。

对于 AWS Bedrock Converse（`aws_converse` 协议），`count_tokens` 仍使用 `upstream_model_id`，而 `converse` / `converse_stream` 使用推理配置 ARN。对于其他所有协议，推理配置值会在每个上游请求中作为模型 ID 使用。

## 模型配置字段

每个模型条目支持以下字段：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `name` | string | （必需） | 规范模型标识符（内部用于统计、日志、路由） |
| `upstream` | string | （必需） | 上游名称 |
| `upstream_model_id` | string | 回退到 `name` | 发送到上游请求体中的模型 ID |
| `upstream_inference_profile` | string | 无 | 最高优先级的上游模型 ID 覆盖（如 AWS Bedrock ARN） |
| `base_url` | string | upstream 的 base_url | 覆盖此模型的端点 URL |
| `aliases` | string[] | `[]` | 客户端可使用的替代名称 |
| `protocol` | string | upstream 的协议 | 覆盖请求/响应格式 |
| `enabled` | bool | `true` | 此模型是否活跃。被禁用的模型会从路由中排除并返回 404。 |
| `hide_name` | bool | `false` | 从客户端访问和模型列表中排除规范 `name`；别名或 LoadBalancer 可用 |
| `direct_path` | bool | `false` | 使用 `base_url` 原样，不追加路径 |
| `kind` | string | 无 | DashScope 模型类型，按类型选择端点路径（如 `dashscope-text-embedding`），无需 `direct_path`。见 [DashScope API](#dashscope-api) |
| `request_payload` | array | `[]` | 模型级载荷规则（覆盖 upstream 规则） |
| `request_transform_before` | script | 无 | 协议翻译前的请求体变换脚本 |
| `request_transform_after` | script | 无 | 协议翻译后的请求体变换脚本 |
| `response_transform` | script | 无 | 变换响应体的脚本 |
| `script_error_mode` | string | `log-and-continue` | 脚本错误处理方式 |
| `count_token` | string | upstream 的模式 | Token 计数模式覆盖 |

### Token 计数模式

`count_token` 字段控制网关如何为速率限制和 `/v1/messages/count_tokens` 端点估算或计数 token：

| 模式 | 说明 |
|------|------|
| `upstream-api` | 转发到上游原生的 token 计数端点（仅 Anthropic、Gemini、AWS Bedrock 支持） |
| `tiktoken` | 使用 `tiktoken-rs` crate 本地估算（OpenAI BPE tokenizer） |

当未设置 `count_token` 时，网关继承上游的默认模式。如果上游不支持原生 token 计数，则回退到 `tiktoken`。

**本地 tiktoken 估算** 会应用模型族修正系数，以补偿 OpenAI tokenizer 与其他提供商之间的词表差异。例如，Qwen 模型使用 1.15 的修正系数，意味着原始 tiktoken 计数乘以 1.15 得到修正后的估算值。这些系数会根据上游 API 报告的实际 token 使用情况，通过指数移动平均（EMA）校准在运行时自动优化。有关修正系数和 EMA 校准算法的详细信息，请参阅[架构文档](architecture.zh-CN.md#token-计数)。

### Model Metadata 系统

Protoflux 使用三层 Model Metadata 架构管理模型能力元数据（tokenizer 家族、修正系数、能力标志等）：

1. **静态目录**（`meta.toml`）：编译期嵌入二进制，定义各模型家族的默认元数据
2. **数据库覆盖**（`config_settings` 表）：通过 Console API 管理，运行时覆盖静态目录的特定字段
3. **内存注册表**（`Arc<ArcSwap<ModelMetadataRegistry>>`）：字段级 overlay 合并，支持热重载

静态目录包含以下模型家族的默认配置：
- **OpenAI GPT 新一代**（`gpt-5*`、`gpt-4.1*`、`o1`、`o3` 等）：`tokenizer_family = "gpt"`、`tiktoken_encoding = "o200k_base"`
- **OpenAI GPT 旧一代**（`gpt-4*`、`gpt-3.5*`）：`tokenizer_family = "gpt"`、`tiktoken_encoding = "cl100k_base"`
- **Anthropic**（`claude*`）：`tokenizer_family = "claude"`、`token_correction_factor = 0.95`
- **Qwen**（`qwen*`、`qwq*`）：`tokenizer_family = "qwen"`、`token_correction_factor = 1.15`、`disable_thinking_by_default = true`
- **DeepSeek**（`deepseek*`）：`tokenizer_family = "deepseek"`、`token_correction_factor = 1.10`
- **Google Gemini**（`gemini*`）：`tokenizer_family = "gemini"`
- **GLM / Kimi / MiniMax**：各自的 tokenizer 家族和修正系数

**Overlay 合并机制**：当多个条目匹配同一模型时，按 `priority` 从低到高合并。高优先级条目的 `Some` 字段覆盖低优先级，`None` 字段保留低层值。例如：

```
qwen-vl-max 匹配：
- qwen*（priority=5）: tokenizer_family=Qwen, factor=1.15, disable_thinking=true
- qwen*-vl*（priority=10）: disable_thinking=false（覆盖）
最终结果：{Qwen, 1.15, disable_thinking=false}
```

**Protocol Fallback**：当模型未匹配到任何真实条目（仅命中 `*` fallback）时，从请求协议推断 tokenizer 家族：
- `DashScope` → `Qwen`
- `Anthropic` → `Claude`
- `Google` → `Gemini`
- 其他 → `Unknown`（使用默认修正系数 1.10）

这使得通过 DashScope 代理的非标准模型名自动获得正确的 tokenizer 配置，无需手动添加元数据条目。

**数据库覆盖示例**：为自定义模型添加 tokenizer 配置

```json
PUT /console/api/settings/model-metadata
[
  {
    "patterns": ["my-custom-model*"],
    "priority": 50,
    "tokenizer_family": "gpt",
    "token_correction_factor": 1.05
  }
]
```

Console API 端点详见 [Console API 文档](console-api.zh-CN.md#model-metadata-管理数据库覆盖层)。

每个 upstream 配置项刻意保持精简，通常只需要：

- `name` — 上游分组标签（如 `openai`、`aliyun-anthropic`）
- `protocol` — 支持的协议 Key（见上表）
- `base_url`
- `api_keys` — 内联数组：`api_keys: [{"key": "...", "weight": 1}]`。多把 key 时按 `weight` **确定性加权分布**（同一调用方粘同一把 key，`weight=0`=备用），算法详见 [上游与模型字段 → 多密钥与权重](user-manual/zh-CN/reference/upstreams-models-fields.md#多密钥与权重)
- `headers` — 附加到每个上游请求的自定义 HTTP 头（可选，JSON object）
- `dns_servers` — 该 upstream 独立的 DNS 服务器列表（可选，IP 或 URI scheme 前缀地址数组；默认：使用系统 resolver）。协议和 TLS SNI 从 URI scheme 自动推断。详见下文「Per-Upstream DNS 解析器」
- `request_payload` — 发送到上游前的请求体修改规则（可选，inline array）
- `enabled` — 该 upstream 是否处于活跃状态（默认：`true`）。被禁用的 upstream 会从路由中排除，其凭证不会被加载。

### 上游自定义 Header

可以通过 `headers` 字段为 upstream 的所有请求附加自定义 header：

在 Console 里配置：**上游** → 新建（或编辑）`openai` → 协议 `openai` / base_url `https://api.openai.com/v1` / headers（自定义头）`{"X-Custom-Header": "my-value", "X-Organization": "my-org"}`。

自定义 header 会在协议特定 header（认证、content-type 等）之后合并到上游请求中，因此可在需要时覆盖默认值。适用于需要通过特殊 header 访问代理、进行灰度发布或传递运维标识等场景。

### 上游自定义 User-Agent

每个 upstream 可以通过 `user_agent` 字段覆盖全局 `server.upstream_user_agent` 设置。设置后，该 upstream 的请求使用自定义 User-Agent 而非全局默认值。

在 Console 里配置：**上游** → 新建 `custom-proxy` → 协议 `openai` / base_url `https://proxy.example.com/v1` / api_keys `[{"key": "sk-...", "weight": 1}]` / user_agent `MyGateway/2.0`。

当 `user_agent` 未设置时，使用全局 `upstream_user_agent`（默认 `"Protoflux"`）。适用于上游代理或供应商要求特定 User-Agent 进行认证或路由的场景。

### Per-Upstream DNS 解析器

每个 upstream 可以配置独立的 DNS 服务器列表（IPv4 或 IPv6），网关通过内嵌的 hickory-resolver **直连**这些 DNS 服务器解析 `base_url` 的 host，**完全绕过容器/OS 层 DNS 缓存**（nscd、systemd-resolved、Docker 127.0.0.11、VPC DNS、CoreDNS 等）。

DNS 服务器同时支持纯 IP 地址和 URI scheme 前缀地址：

| Scheme | 协议 | 默认端口 |
|--------|------|----------|
| （无） | UDP（标准 DNS） | 53 |
| `udp://` | 标准 DNS（UDP + TCP 回退） | 53 |
| `tls://` | DNS-over-TLS (DoT) | 853 |
| `https://` | DNS-over-HTTPS (DoH) | 443 |

URI scheme 允许在同一个 resolver 内混合不同传输协议。纯 IP 无 scheme 前缀时默认使用 UDP。`tls` 和 `https` 协议所需的 TLS SNI 主机名对知名供应商（Cloudflare、Google、阿里、Quad9）自动检测；URI 中的主机名直接用作 SNI。

- **AliDNS 公共（UDP）**：**上游** → 新建 `aliyun-china` → 协议 `anthropic` / base_url `https://dashscope.aliyuncs.com/apps/anthropic` / api_keys `[{"key": "sk-...", "weight": 1}]` / DNS 服务器 `223.5.5.5, 223.6.6.6`。
- **DNS-over-TLS（DoT）**：**上游** → 新建 `cloudflare-dot` → 协议 `openai` / base_url `https://api.openai.com` / api_keys `[{"key": "sk-...", "weight": 1}]` / DNS 服务器 `tls://1.1.1.1, tls://1.0.0.1`（dns_server_name 自动推断为 `1dot1dot1dot1.cloudflare-dns.com`）。
- **DNS-over-HTTPS（DoH）**：**上游** → 新建 `google-doh` → 协议 `openai` / base_url `https://api.openai.com` / api_keys `[{"key": "sk-...", "weight": 1}]` / DNS 服务器 `https://8.8.8.8`（dns_server_name 自动推断为 `dns.google`）。
- **无 DNS**：**上游** → 新建 `openai` → 协议 `openai` / base_url `https://api.openai.com` / api_keys `[{"key": "sk-...", "weight": 1}]`（无 DNS 服务器 → 系统默认 resolver）。

**解决的根因**：容器/OS 层 DNS 缓存了上游 ALB 的旧 IP，ALB IP 轮换后旧 IP 失效但缓存未过期，网关的 zombie-pool flush 自愈链即使重建连接也会解析到同一个坏 IP → 持续 503/504/超时无法自愈。per-upstream DNS 让每个 upstream 拿到**新** IP，自愈链恢复有效。

**运行时机制**：
- DNS 服务器配置存储在 DB 的 `extra_config.dns_servers` 扩展字段（不增加 DB 列）。协议和服务器名每次加载时从第一个 URI 自动推断，无需单独存储。Console UI 编辑时同步落到对应字段。
- 相同推断后的 `(dns_servers, dns_ttl_secs, protocol, server_name)` 四元组的多个 upstream 共享同一个 resolver 实例（`resolver_cache`），避免重复建 hickory。不同 protocol 或 server_name 会产出独立 resolver。
- 网关内嵌 TTL-capped DashMap 缓存（可通过 `dns_ttl_secs` 配置，默认 30s），hickory 自己的 `DnsCachingClient` 通过 `cache_size = 0` **关闭**——避免双层缓存叠加（`our_ttl + alb_dns_ttl` 反而加剧陈旧问题）。
- 同 host 查询 singleflight：并发 cache miss 共享同一次上游 DNS 查询（per-host mutex），避免惊群式重复查询。
- Stale-while-revalidate-lite：DNS 查询失败时若缓存有陈旧条目则回退使用（warn 日志），避免上游 DNS 短暂故障拖垮请求。
- HTTP client 按 `(proxy, dns_servers, dns_ttl_secs, protocol, server_name)` 五字段复合键分桶——同 proxy 不同 DNS 配置的 upstream 不会共享连接池，避免解析结果串扰。
- 健康探测（`health_prober`）也走同一组 dns_servers，确保 zombie flush 决策和新建连接解析到同一组 IP。

**可观测性日志**（trace/debug 级）：
- `per-upstream DNS resolver initialized`（info，构造时打）
- `get_or_create_resolver: cache HIT/MISS`（trace/debug，resolver 复用）
- `client_for_auth: resolved ClientKey`（trace，每个请求的复合键）
- `dns_resolver: cache HIT (fresh) / MISS / fresh lookup succeeded`（trace/debug，host 级解析详情，含耗时、解析到的 IP 列表）
- `dns_resolver: lookup failed, serving STALE cache`（warn，陈旧回退）

**未配置 upstream 的兜底 DNS 池**：不配 `dns_servers` 的 upstream 默认走 reqwest 系统 `GaiResolver`（getaddrinfo），不经 `GatewayDnsResolver`，无 SWR 缓存 / SSRF 守卫 / 失败计数 / 系统慢挂兜底。基础设施配置项 `fallback_dns_servers`（默认 `["1.1.1.1", "8.8.8.8", "119.29.29.29", "223.5.5.5"]`）非空时，此类 upstream 改走一个合并池 resolver：server 池 = [系统 `/etc/resolv.conf` nameserver] + [本列表]，系统健康时本地低延迟优先、系统慢/挂时公网接管，且 protoflux 全套 DNS 机制（SWR/SSRF/failover/计数）生效。设 `fallback_dns_servers = []` 可禁用（回退 `GaiResolver`，后向兼容逃生口）。Docker 下可通过 `FALLBACK_DNS_SERVERS` 环境变量覆盖（逗号分隔；Dockerfile 默认即这四个根，置空则禁用）。合并池不读 `/etc/hosts`、不解析 `localhost`/`.local`（系统 nameserver 走 hickory UDP 查询非 getaddrinfo）——若含本地 upstream，用单 upstream 逃生 `dns_servers = ["127.0.0.1"]` 或全局禁用。详见架构文档「上游 DNS 解析器」。

**已知约束**：
- 未实现后台异步 revalidate（SWR 只做到 stale-fallback，没做主动刷新）。
- 纯 DNS 服务器（无 URI scheme）只支持 IP 地址；URI scheme 条目（`tls://`、`https://`）同时支持 IP 和主机名（如 `tls://dot.pub`、`https://doh.pub/dns-query`）。
- DNS 服务器默认走 UDP 53 + TCP fallback；如出口防火墙拦 UDP 53 需提前放开。DoT 需开放 TCP 853；DoH 需开放 TCP 443。
- 配置错误（非法 IP 或无法解析的主机名）会降级为系统 resolver 并 warn，不会 panic。

### Header 和 User-Agent 模板变量

上游 `headers` 的值和 `user_agent` 字段都支持使用 `{{variable}}` 格式的内置模板变量。变量在请求时根据当前请求上下文进行替换，可用于上游日志关联和用户感知路由。

| 变量 | 来源 | 说明 |
|------|------|------|
| `{{request_access_key_name}}` | 认证 access key 名 | 匹配的 `Access Key` 条目中的名称 |
| `{{request_access_key_group}}` | access key 分组名 | 认证 access key 所属的分组 |
| `{{request_id}}` | `Protoflux-Request-ID` 请求头 | 客户端 `Protoflux-Request-ID` 头中的唯一请求 ID |
| `{{access_key_hash}}` | access key 名的 SHA-256 哈希 | access key 名 SHA-256 哈希的前 16 字节，base64 编码（22 字符，确定性输出） |
| `{{header:Key}}` | 客户端请求头 | 客户端 `Key` 请求头的值（不区分大小写，如 `{{header:X-Trace-Id}}`） |
| `{{system_prompt_hash}}` | system 提示词内容的 SHA-256 | system 提示词内容的 sha256 小写十六进制摘要，协议无关，兼容 Anthropic `system`、OpenAI `messages[role=system]`、DashScope `input.messages[role=system]`、Google `system_instruction` 四种放置方式。基于上游请求体计算，因此这里注入的上游 header 与 `request_payload` 的 `value` 规则写入同一个哈希时取值一致（见[上游请求体改写](#上游请求体改写)） |

如果变量不可用（如未认证 access key），则替换为空字符串。当替换后的值完全为空时，该 header 不会发送。

**回退链语法**：`{{var1|var2|var3}}` 用 `|` 分隔多个候选变量，按顺序取第一个可用的——前一个不可用（未认证 / header 缺失）则试下一个，全部不可用则替换为空字符串。例：`{{header:X-Trace-Id|request_id}}` 优先用客户端 `X-Trace-Id` 头，缺失则回退到网关请求 ID。`{{header:Name}}` 也可作为回退链的一员。未知变量（不在上表、非 `header:` 前缀）在链中被 skip 而非报错。模板校验在配置加载（`assemble_config`，DB→runtime 合并点）时 warn-don't-block——DB 加载的坏模板实体仍加载、仅日志告警，不锁死启动；Console 写路径已在上游拒收坏模板。

**示例 — 在 User-Agent 中包含 access key 名以便上游日志关联：**

在 Console 里配置：**上游** → 新建 `litellm-proxy` → 协议 `openai` / base_url `https://proxy.example.com/v1` / user_agent `Protoflux/key-{{request_access_key_name}}` / headers `{"X-Request-Access-Key": "{{request_access_key_name}}", "X-Request-Group": "{{request_access_key_group}}"}`。

**示例 — 多级回退的上游缓存键 header：**

`{{system_prompt_hash}}` 在 header 模板里和 `request_payload` 的 `value` 表达式一样可用。同样的回退链语法在此上下文也适用，且全部哈希/身份变量在此上下文可用，因此一个 header 就能承载一个逐级优雅降级的稳定缓存键：

在 Console 里配置：**上游** → 新建 `dashscope-multimodal` → 协议 `dashscope` / base_url `https://dashscope.aliyuncs.com/api/v1` / headers `{"X-Conversation-Id": "{{header:x-claude-code-session-id|access_key_hash|system_prompt_hash}}"}`。

回退链从左到右取第一个非空值：

| 级 | 变量 | 命中条件 | 缓存键粒度 |
|------|----------|--------------|----------------------|
| 1 | `{{header:x-claude-code-session-id}}` | 客户端发送了会话头 | 同会话同键（最细，单次会话内命中上游缓存） |
| 2 | `{{access_key_hash}}` | 无会话头，但已认证 | 同 access key 同键（中等，同一用户的会话间共享缓存） |
| 3 | `{{system_prompt_hash}}` | 无会话头且无 access key | 同 system 提示词同键（最粗，无 system 提示词时退化为 `sha256("")`） |

哈希基于上游请求体计算——与 `request_payload` 的 `value` 规则看到的同一份 body 一致，因此 header 与 body 规则写同一个变量时取值完全相同。当请求既无会话头、又无认证 key、又无 system 提示词时，整条链解析为空，该 header 被**省略**（而非发送一个退化常量）；若想更早省略，则不要把 `system_prompt_hash` 放进链——因为它单独存在时会退化为固定的 `sha256("")`。

> ℹ️ `system_prompt_hash` 与 `access_key_hash` 只在模板实际引用时才计算，放进回退链对不使用它们的模板永远不会多花一次哈希。

### 上游请求体修改（Request Payload）

可以通过 `request_payload` 字段在请求发送到上游前修改 JSON 请求体。**改写类**规则（`overwrite` / `add-if-absent` / `append` / `append-if-missing` / `remove` / `remove-matching` / `strip-lines` / `filter-content-types` / `filter-tools` / `inject-system-prompt`）在**协议转换之后**应用，因此影响的是最终上游（目标协议）请求体的格式。**唯一的例外是 `switch-route`**——它不改写请求体，且在协议转换**之前**、按**客户端**请求体的形状评估 `when`，以便支持跨协议切换（详见下方模式表与「条件切路由」一节）。每个修改条目包含以下字段：

| 字段 | 类型 | 说明 |
|------|------|------|
| `path` | String | 点分隔的 JSON 路径（如 `temperature`、`response_format.type`、`messages.0.content`）。`switch-route` 模式不使用该字段（其判定走 `when`），可留空 |
| `value` | JSON | 要设置、追加或删除的值。字符串值若含 `{{...}}` 则按表达式解析（见下），否则作字面量；非字符串值原样写入 |
| `mode` | String | **必填。** 见下方模式表 |
| `condition` | Object | `append-if-missing`、`remove-matching`、`strip-lines` 和 `filter-tools` 模式的条件对象。这四种模式必填，其他模式忽略 |
| `when` | Array | `switch-route` 模式的谓词列表（OR 语义：任一成立即触发）。每个谓词为 `{"path": "...", "eq": <值>}`：省略 `eq`（或 `eq: null`）表示**字段存在性**检查，给出 `eq` 表示相等检查。`path` 支持 `*` 通配，通配段取存在性语义（数组中任一元素该子路径存在即视为存在）。其他模式忽略 |
| `use_model` | String | `switch-route` 模式触发时写入的路由目标模型名。为空则空操作。目标可与当前路由**同协议或跨协议**：`switch-route` 在协议转换前触发 eager 交换，跨协议目标会被正确重翻译（与 before 槽脚本 `context.useModel` 一致）。切换目标视为**管理员指定的内部路由**：网关**不会**对 `use_model` 目标重新校验调用方所属分组的模型 ACL（模型 ACL 只在请求入口对客户端原始请求的模型校验一次）；因此可把只有 A 模型权限的用户**透明改路**到 B 模型——这是该特性的本意，而非权限漏洞（该规则只能由管理员配置，无不可信输入）。其他模式忽略 |

**`value` 表达式：**

在写入值的模式（`overwrite`/`add-if-absent`/`append`/`append-if-missing`）下，`value` 若是**含 `{{...}}` 的字符串**，则按表达式模板解析；不含 `{{` 的字符串作字面量原样写入，非字符串的 JSON 值（数字/布尔/对象/数组）永不解析。表达式用 `|` 连接回退链，取首个非空结果；整条链全空时**省略本次写入**（该路径不被创建，而非写入空串或常量）。

该上下文可用的变量：`header:Name`（客户端请求头，大小写不敏感；头值前后空白视为缺失）、`request_id`、`request_access_key_name`、`request_access_key_group`、`system_prompt_hash`（system 提示词内容的 sha256 小写十六进制，协议无关，兼容 Anthropic `system`、OpenAI `messages[role=system]`、DashScope `input.messages[role=system]`、Google `system_instruction` 四种放置方式）。哈希由与 header 模板上下文相同的例程、相同的上游请求体计算（见 [Header 和 User-Agent 模板变量](#header-和-user-agent-模板变量)），因此 header 与 `value` 规则写同一个 `{{system_prompt_hash}}` 时取值一致。注意 `access_key_hash` 在此上下文恒为空——请求体改写阶段不持有密钥原文，缓存键兜底应使用 `system_prompt_hash`。

典型用途是为上游私有缓存字段（如 Kimi 的 `prompt_cache_key`）注入"会话头优先、system 哈希兜底"的动态键，无需脚本：

在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `overwrite` / 路径 `prompt_cache_key` / 值 `{{header:x-claude-code-session-id|system_prompt_hash}}`。

上例：请求带会话头时用头值（同会话同键，命中上游缓存）；头缺失时用 system 哈希（同 system 同键）。若请求既无会话头也无 system 提示词，`system_prompt_hash` 退化为 `sha256("")` 的固定值——这是把哈希放进回退链的知情取舍；若希望此时完全不注入，则不要把 `system_prompt_hash` 放进链（链全空即省略写入）。

> ℹ️ 改写类规则在协议转换**之后**应用，因此写入 `prompt_cache_key` 这类上游私有字段不会被翻译丢弃，正好满足"缓存键必须在翻译后注入"的要求（`switch-route` 不在此列，它在转换前评估且不写请求体）。

**模式：**

| 模式 | 目标 | 说明 |
|------|------|------|
| `overwrite` | 任意 | 替换目标路径的值。自动创建中间路径 |
| `remove` | 任意 | 删除目标路径的键。路径不存在则为空操作 |
| `add-if-absent` | 对象字段 | 仅当键不存在时设置值 |
| `append` | 数组 | 无条件追加到数组。路径不存在时自动创建 |
| `append-if-missing` | 数组 | 仅当 scope 数组中**没有元素**匹配 `condition.where` 子句时才追加 |
| `remove-matching` | 数组 | 移除 scope 数组中所有匹配 `condition.where` 子句的元素 |
| `strip-lines` | 字符串 | 从字符串值中移除匹配 `condition.where` 子句的行。使用特殊路径（`"system"`、`"messages"`、`"input"`）可自动遍历所有消息。Action：`remove-line`（移除匹配行）、`remove-line-cleanup`（移除行并清理尾部空白）、`remove-block`（从开始匹配到结束匹配之间全部移除） |
| `filter-content-types` | 数组 | 从 `messages[].content[]` 中移除指定类型的内容块。使用特殊路径定位特定协议，或将 `path` 留空以自动展开到所有已知路径。`value` 为单个类型字符串（如 `"image"`）或类型数组（如 `["image", "video"]`） |
| `filter-tools` | 数组 | 根据 `condition.where` 子句过滤 `tools` 数组。`value` 可选：空/null 则仅移除匹配工具；非空则先移除匹配工具再追加指定工具。同时自动清理引用已被移除工具的 `tool_choice` |
| `switch-route` | 控制面 | **不修改请求体**。当 `when` 中任一谓词成立时，把本次请求的路由目标切换为 `use_model`。**与其它模式不同，它在协议转换之前评估**，谓词路径按**客户端**请求体的形状书写（如 Anthropic 客户端用 `messages.*.content.*.type` 等于 `"image"`），命中后触发 eager 交换，**支持跨协议目标**（与 before 槽脚本 `context.useModel` 一致）。谓词只做结构/小值检查，永不读取二进制内容字节，因此即使 `when` 路径命中 `image`/`image_url` 等二进制键，大请求体的流式落盘门也保持开启（body 留在文件里，不会整段物化进内存）。用于"检测到多模态/联网特征就换模型"这类纯路由决策，是脚本 `useModel(...)` 的原生、内存敏感替代 |

路径不存在时自动创建中间对象和数组。数字路径段视为数组索引（如 `messages.0.content`）。

**路径语法：**

`path` 字段使用点分隔的段在 JSON 请求体中导航。每段要么是对象键，要么是数字数组索引。

| 语法 | 示例 | 说明 |
|------|------|------|
| 对象键 | `temperature` | 顶层字段 |
| 嵌套键 | `response_format.type` | 深入嵌套对象 |
| 数组索引 | `messages.0.content` | 访问索引 0 的元素（纯数字，无括号） |
| 通配符 | `messages.*.content` | 匹配数组的**所有**元素（所有模式均可用） |
| 深层嵌套 | `messages.0.content.1.text` | 自由混合对象键与数组索引 |
| 嵌套通配符 | `messages.*.content.*.text` | 多个 `*` 段用于深层嵌套数组 |

> ⚠️ `messages[].content`、`messages[*].content`、`messages[0].content` 是**非法**的。通配符用 `messages.*.content`，具体索引用 `messages.0.content`。

**通配符行为：**
- `*` 遍历数组的所有元素。若当前节点不是数组则为空操作。
- 通配符路径只作用于**已存在**的节点——不会创建新数组元素或中间路径。
- 适用于所有模式：`overwrite`、`remove`、`add-if-absent`、`append`、`strip-lines`。

**`strip-lines` 与 `filter-content-types` 的特殊路径：**

以下保留路径值会自动遍历数组元素（等价于使用 `*`，但更简短）：

| 路径 | 遍历对象 | 协议 | `strip-lines` | `filter-content-types` |
|------|----------|------|:---:|:---:|
| `"system"` | `body.system`（字符串或内容块数组） | Anthropic | ✅ | ✅ |
| `"messages"` | `body.messages[].content` | Anthropic / OpenAI Chat / Bedrock | ✅ | ✅ |
| `"input"` | `body.input[].content` 或 `body.input` 作为字符串 | OpenAI Responses API | ✅ | ✅ |
| `"contents"` | `body.contents[].parts` | Google Gemini | ❌ | ✅ |
| `"input.messages"` | `body.input.messages[].content` | DashScope | ❌ | ✅ |

> ℹ️ `"contents"` 与 `"input.messages"` 只对 `filter-content-types` 有效。这些协议下使用 `strip-lines` 时须改用通配符路径（如 `contents.*.parts.*.text`）。

对于 `overwrite`、`remove`、`append` 等模式中的非通配符路径，路径解析为单个值——使用显式索引如 `messages.0.content` 来定位特定元素。

**简单覆盖：**
在 Console 里配置（不用手写 JSON）：**上游** → 新建（或编辑）`openai` → 协议 `openai` / base_url `https://api.openai.com/v1` → 请求体规则 → **添加规则** → 模式 `overwrite` / 路径 `temperature` / 值 `0.7`。

**多级嵌套覆盖：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `overwrite` / 路径 `response_format.type` / 值 `json_schema`。

**删除字段：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `remove` / 路径 `temperature`（`remove` 模式无需填值）。

**不存在时添加（保留客户端原始值）：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `add-if-absent` / 路径 `top_p` / 值 `0.9`。

**追加到数组：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `append` / 路径 `tools` / 值 `{"type": "function", "function": {"name": "web_search", "description": "Search the web"}}`。

**追加到 messages 数组：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `append` / 路径 `messages` / 值 `{"role": "system", "content": "You are a helpful assistant"}`。

**组合多条规则：**
在 Console 里配置（不用手写 JSON）：上游（或模型详情）→ 请求体规则 → **添加规则**，按顺序加 4 条：

| # | 模式 | 路径 | 值 |
|---|------|------|-----|
| 1 | `overwrite` | `temperature` | `0.3` |
| 2 | `overwrite` | `max_tokens` | `4096` |
| 3 | `overwrite` | `response_format.type` | `json_schema` |
| 4 | `append` | `tools` | `{"type": "function", "function": {"name": "calculator"}}` |

规则按顺序应用，后面的条目可以覆盖前面同路径的条目。

##### 过滤内容类型

`filter-content-types` 模式从消息内容数组中移除指定类型（如 `image`、`video`）的内容块。移除图片块时，会在该消息首个被移除块的位置自动注入一个文本块作为占位说明，使模型知晓用户曾附带图片但当前模型不支持图片输入——避免静默丢弃导致模型对后续内容的误解。

**从 messages 中移除图片块：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `filter-content-types` / 路径 `messages` / 值 `image`。

**从所有已知路径移除多种内容类型（自动展开）：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `filter-content-types` / 路径留空（自动展开到所有已知路径）/ 值 `["image", "video"]`。

**从 OpenAI Responses API 的 input 中移除图片块：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `filter-content-types` / 路径 `input` / 值 `image`。

**占位说明文本（`hint`）：**

移除内容块时注入的占位文本可通过 `hint` 字段自定义。未配置 `hint` 且过滤类型包含 `image` 时，使用默认文本：

> [Note: The user sent image(s) in this message, but they were removed because the current model does not support image input. Please respond based on the text content only.]

- 配置 `hint` 后，每条消息首个被移除块的位置插入一个内容为该 `hint` 的文本块。
- `hint` 设为空字符串 `""` 会注入一个空文本块（并非禁用注入）。
- 占位文本仅在确实有块被移除时注入；嵌套在 `tool_result` 内部的图片块仅被移除，不注入占位文本。
- 注入块的格式按目标协议自适应：Anthropic / OpenAI / Bedrock / DashScope 为 `{"type":"text","text":...}`，Google 为 `{"text":...}`。
- 经 Console API 配置时不支持 `hint` 字段（只能使用默认文本）；自定义占位文本须直接写入数据库中的 `request_payload` 规则。

##### 条件数组操作

`append-if-missing` 和 `remove-matching` 模式对数组进行条件操作，需要提供 `condition` 对象，包含两个字段：

| 字段 | 类型 | 说明 |
|------|------|------|
| `scope` | String | 点分隔的数组路径，用于检查其中的元素（如 `messages`） |
| `where` | 对象数组 | 必须全部匹配的子句（AND 逻辑）。每条子句包含 `path`（相对于数组元素的路径）和 `eq`（匹配值） |

**条件追加 thinking block（不存在时才追加）：**
在 Console 里配置（不用手写 JSON）：**模型** → 新建（或编辑）`kimi-2.6` → upstream `anthropic` → 请求体规则 → **添加规则** → 模式 `append-if-missing` / 路径 `messages` / 值 `{"type": "thinking", "thinking": {"budget_tokens": 10000}}` → **条件**：scope `messages`，加一条 where 子句——路径 `type` / 等于 `thinking`。

确保上游请求的 messages 数组中始终包含 thinking block，但如果客户端已经发送了则不会重复追加。

**移除 thinking block：**
在 Console 里配置（不用手写 JSON）：**模型** → 新建（或编辑）`kimi-2.6-lite` → upstream `anthropic` → 请求体规则 → **添加规则** → 模式 `remove-matching` / 路径 `messages`（无需填值）→ **条件**：scope `messages`，加一条 where 子句——路径 `type` / 等于 `thinking`。

**条件追加 system 消息：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `append-if-missing` / 路径 `messages` / 值 `{"role": "system", "content": "You are a helpful assistant."}` → **条件**：scope `messages`，加一条 where 子句——路径 `role` / 等于 `system`。

**多条 where 子句（AND 逻辑）：**
在 Console 里配置：上游（或模型详情）→ 请求体规则 → **添加规则** → 模式 `append-if-missing` / 路径 `messages` / 值 `{"type": "text", "role": "system", "text": "You are helpful."}` → **条件**：scope `messages`，加两条 where 子句（AND）——① 路径 `type` / 等于 `text`；② 路径 `role` / 等于 `system`。

只有当没有元素**同时**满足 `type = "text"` **和** `role = "system"` 时才会追加。

**条件切路由（`switch-route`，内存敏感、协议无关）：**

一句话：**「如果请求体里出现了 X，就把这次请求改发给另一个模型」**——它不改请求体的任何内容，只换「这次该发给谁」。最典型的用法是「用户发了图片 / 开了联网搜索，就切到能看图 / 能联网的模型」。

> ⚙️ **评估时机与路径形状（重要）**：`switch-route` 在**协议转换之前**评估，`when` 的路径按**客户端**请求体的形状书写——也就是「调用方用什么协议发请求，就按那种协议的字段结构写路径」。例如 Anthropic 客户端的图像块是 `messages.*.content.*.type` 等于 `"image"`，OpenAI Chat 客户端是 `messages.*.content.*.image_url`（存在性），DashScope 客户端是 `input.messages.*.content.*.image`（存在性）。正因为评估发生在翻译之前，命中后的 eager 交换**支持跨协议目标**——网关会用目标协议的翻译器重新翻译客户端 body，与 before 槽脚本 `context.useModel` 行为一致。若同一条规则要兼容**多种客户端协议**，就在 `when` 里用 OR 把各客户端形状的路径都列上（见下例）。这与改写类规则相反：改写类规则在翻译**之后**跑，路径按**目标**协议形状写。

在 Console 里配置（不用手写 JSON）：打开**模型详情**（或上游的「默认请求体规则」）→ 找到**请求体规则** → 点 **+ 添加规则** → 在中间的模式下拉里选 **`switch-route`** → 这时该行不再显示「路径 / 值」，而是展开两个专属控件：

- **切换到模型 (use_model)**：命中后要改发去的模型名。✅ 它可以和当前模型**同协议或跨协议**——`switch-route` 在翻译前触发 eager 交换，跨协议目标会被重新翻译。
- **命中条件 when**：一条或多条「判定」。每条判定填一个**字段路径**（按**客户端**协议形状写，见上方提示框），可选再填一个**等于**值：
  - **等于留空** = 「只要这个字段存在就算命中」（用来检测「有没有图片」这种存在性）。
  - **填了等于** = 「这个字段的值必须等于它才算命中」（如 `true`、`image`）。
  - 路径里的 `*` 表示「数组里的任意一个元素」，例如 `messages.*.content.*.type` = 「任意一条消息的任意一个内容块里的 `type` 字段」（具体路径随客户端协议而变）。
  - 多条判定之间是「**或**」：**任意一条**命中就切换；全不命中则维持原模型。

最简单的例子——「请求里带图就切到视觉模型」（单条判定，存在性检查；**此例假设客户端用 DashScope 协议**，故路径是 `input.messages.*.content.*.image` 形态）。Console 表单配置步骤：

1. 模型详情 → 请求体规则 → **添加规则** → 模式选 `switch-route`；
2. **切换到模型** 下拉选 `qwen-vl-max`；
3. 加一条判定：路径填 `input.messages.*.content.*.image`，「等于」留空（即存在性检查）。

读法：当请求体里**任意**消息的**任意**内容块含有 `image` 字段时，把这次请求改发给 `qwen-vl-max`；否则照常发给本模型。注意 `switch-route` 按**客户端**形状评估，这条 `input.messages.*` 路径只对 **DashScope 客户端**命中；若调用方用 Anthropic / OpenAI 协议发请求，须把路径换成对应客户端形态，或像下一例那样用 OR 把多种客户端形态都列上。

> 提示：`switch-route` 只判「字段在不在 / 小值等不等于」，**从不读取图片 base64 等二进制内容**。正因如此，含大图的大请求体仍按流式落盘留在文件里、不会被整段读进内存——这是它相比脚本 `context.useModel(...)` 的关键优势：脚本会把整个 body（含内联 base64）物化进解释器堆，多模态大对话上容易 OOM，而 `switch-route` 不会。所以「检测特征 → 换模型」这类**纯路由决策**，优先用 `switch-route`，不要写脚本。

`switch-route` 不读写请求体内容，只在 `when` 谓词成立时把本次请求改投到 `use_model`。它原生替代了脚本里的 `context.useModel(...)`，且因为谓词只做结构/小值检查、永不解引用二进制字节，**大请求体的流式落盘门保持开启**——含 base64 图像的多模态大 body 仍以 sentinel + 磁盘 blob 留在文件里，不会像脚本那样被整段物化进解释器堆而 OOM。

`when` 是 OR 语义的谓词列表，路径全可配，因此同一规则可覆盖多种**客户端**协议的图像/联网特征（`switch-route` 按客户端形状评估，故这里列的是各客户端协议的字段形态）。模式选 `switch-route`、目标下拉选 `qwen3.7-max`，加四条判定（每条 = 路径 + 可选「等于」）：

| # | 路径 | 等于 | 命中 |
|---|------|------|------|
| 1 | `input.messages.*.content.*.image` | 留空 | DashScope 客户端图像块（存在性） |
| 2 | `input.messages.*.content.*.image_url` | 留空 | OpenAI 客户端图像块（存在性） |
| 3 | `input.messages.*.content.*.type` | `image` | Anthropic 客户端图像块 |
| 4 | `parameters.enable_search` | `true` | 任意协议的联网开关 |

这四条分别对应 DashScope 客户端图像块、OpenAI 客户端图像块、Anthropic 客户端图像块、以及任意协议的联网开关——换**客户端**协议只改 JSON 路径，不动代码；用 OR 把多种客户端形态并列，同一条规则即可对任意客户端协议生效。任一谓词成立即切到 `qwen3.7-max`；都不成立则维持原路由。`use_model` 目标可与当前路由同协议或跨协议。

`switch-route` 可与 `strip-lines` 等改写规则组合，完整替代一段"检测特征切路由 + 过滤 system 前缀"的 after 槽脚本。注意二者评估阶段不同：`switch-route` 在翻译**前**按**客户端**形状判定，`strip-lines` 在翻译**后**按**目标**形状改写——下面退役案例里两者路径都写成 `input.messages.*` 形态，是因为该案例**假设客户端用 DashScope 协议**（此时客户端形状恰与目标形状相同）。配置两条规则：

1. **switch-route**：目标下拉选 `qwen3.7-max`；加两条判定——路径 `input.messages.*.content.*.image`（等于留空）、路径 `parameters.enable_search`（等于 `true`）。
2. **strip-lines**：路径 `input.messages.*.content`；匹配 `starts_with`，值为 `You are Claude Code, Anthropic's official CLI`；动作 `remove-line-cleanup`。

> ℹ️ 在模型上配好上述原生规则后，经 Console 清空该模型的 after 槽内联脚本即可退役原 JS——脚本一移除，该路由的落盘门即对原生规则保持开启。注意 `strip-lines` 在落盘门开启时只作用于未被 blobify 的文本块（system 提示恒为几 KB，不受影响）；若某文本块超过 blob 阈值被落盘为 sentinel，则该块不被剥离——这是相对原脚本（在大 body 上直接 OOM/静默跳过）的知情边界，原生版严格更优。

**完整脚本退役案例（after 槽 `transform(body, context)` → 原生规则）：**

下面这段 after 槽脚本做三件事——检测图像块、检测联网开关、二者命中则 `useModel` 切到多模态模型，并剥离 system 提示里以固定前缀开头的行。它在多模态大对话上会触发解释器堆 OOM（脚本拿到的 `body` 是已整段物化的对象，含内联 base64）。可直接用 `switch-route` + `strip-lines` 等价替换。**前提**：本例目标协议为 DashScope，after 槽脚本所见的 `body` 是翻译后的 DashScope 形态，对任意客户端都成立；而 `switch-route` 在翻译**前**按**客户端**形状判定，故下面的原生等价**假设客户端也用 DashScope 协议**——此时客户端形状与脚本所见的翻译后形状相同，`switch-route` 的 `when` 路径才能与脚本字段路径一一对应。若客户端用其它协议，须把 `switch-route` 的 `when` 改列对应客户端形态（或用 OR 并列多种，见上例），`strip-lines` 的路径则保持不变（它仍按目标形态改写）：

```js
function transform(body, context) {
  const msgs = body.input?.messages;
  // 1. 检测图像块：DashScope 多模态块形如 {"image": "<url>"}（无 type 字段）
  let hasImage = false;
  if (Array.isArray(msgs)) {
    for (const msg of msgs) {
      if (!Array.isArray(msg.content)) continue;
      if (msg.content.some(b => b.image)) { hasImage = true; break; }
    }
  }
  // 2. 图像 OR 联网 → 切到多模态模型（必须用 useModel：目标在另一个端点）
  if (hasImage || body.parameters?.enable_search) {
    context.useModel("qwen3.7-max");
  }
  // 3. 剥离 system 提示里以前缀开头的行，块变空则整块删除
  const PREFIX = "You are Claude Code, Anthropic's official CLI";
  if (!Array.isArray(msgs)) return body;
  for (let i = msgs.length - 1; i >= 0; i--) {
    const msg = msgs[i];
    if (msg.role !== "system" || !Array.isArray(msg.content)) continue;
    msg.content = msg.content.filter(block => {
      if (block.type !== "text" || typeof block.text !== "string") return true;
      if (!block.text.startsWith(PREFIX)) return true;
      const remaining = block.text.split("\n").filter(l => !l.startsWith(PREFIX));
      block.text = remaining.join("\n");
      return block.text.trim().length > 0;
    });
    if (msg.content.length === 0) msgs.splice(i, 1);
  }
  return body;
}
```

等价原生配置。注意两条规则的评估阶段：`strip-lines` 在协议转换**之后**跑，路径按**目标**形态写（本例目标为 DashScope，故 `input.messages.*.content`）；`switch-route` 在协议转换**之前**跑，路径按**客户端**形态写——本例客户端恰为 DashScope，故其 `when` 路径与 `strip-lines` 同为 `input.messages.*` 形态，看起来一样，但语义来源不同。配置两条规则：

1. **switch-route**：目标下拉选 `qwen3.7-max`；加两条判定——路径 `input.messages.*.content.*.image`（等于留空）、路径 `parameters.enable_search`（等于 `true`）。
2. **strip-lines**：路径 `input.messages.*.content`；匹配 `starts_with`，值为 `You are Claude Code, Anthropic's official CLI`；动作 `remove-line-cleanup`。

逐条对应关系：

- `msg.content.some(b => b.image)`（只判键存在、不读 base64）→ 一条判定：路径 `input.messages.*.content.*.image`、等于留空（存在性检查，`*` 通配逐层展开消息与内容块）。
- `body.parameters?.enable_search`（适配器已把 web_search 转成该布尔）→ 一条判定：路径 `parameters.enable_search`、等于 `true`。
- `hasImage || enable_search` 的 OR → `when` 列表天然是 OR 语义，任一成立即触发。
- `context.useModel("qwen3.7-max")` → `use_model`。`switch-route` 走翻译前的 eager 交换，**支持跨协议目标**；本例目标与原 after 槽脚本同为同协议端点切换，故二者等价。原 after 槽脚本的 `useModel` 受 after 位置限制只能同协议，而 `switch-route` 无此限制——这正是把"检测特征切路由"从 after 槽脚本迁到 `switch-route` 的额外收益。
- system 前缀过滤 → `strip-lines` + `match.starts_with` + `action: remove-line-cleanup`：按行匹配前缀、移除匹配行、块清空后整块删除，与原 `filter` + `splice` 等价；它只作用于带 `text` 字符串的块，图像块（无 `text`）原样保留，等同原脚本里 `block.type !== "text"` 的守卫。

> ℹ️ 配好上述规则后，经 Console 清空该模型的 after 槽内联脚本即完成退役；脚本一移除，该路由的落盘门即对原生规则保持开启，含 base64 的大 body 以 sentinel + 磁盘 blob 留在文件里，不再物化进解释器堆。细微差异：若某 system 文本块**仅**由前缀行与空行组成，原脚本会保留一个空文本块，而 `remove-line-cleanup` 会移除该空块——行为更干净，且不影响模型输入。

#### 模型级请求体修改

`request_payload` 也可以在模型层级配置。模型级规则优先级高于 upstream 级规则。两套规则会合并，upstream 规则先应用，然后模型规则 — 因此模型规则可以覆盖或扩展 upstream 的同路径规则。

在 Console 里配置：**模型** → 新建 `qwen3.6-plus-strict` → 上游 `openai` → 请求体规则 → **添加规则**，按顺序加 2 条：

| # | 模式 | 路径 | 值 |
|---|------|------|-----|
| 1 | `overwrite` | `temperature` | `0` |
| 2 | `overwrite` | `response_format.type` | `json_schema` |

适用于不同模型需要不同的请求体修改但共享同一个 upstream 配置的场景。例如 upstream 为所有模型设置默认的 `temperature`，但特定模型覆盖为 `0` 以获得确定性输出。

#### 模型级协议覆盖

模型可以通过 `protocol` 字段覆盖 upstream 的协议。结合 `base_url`，可以让同一组 upstream 凭据为不同 API 格式的多个模型提供服务（例如一个端点同时提供 OpenAI 和 Anthropic 兼容的路径）。

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建 `aws-global` → 协议 `aws_converse` / base_url `https://bedrock-runtime.us-west-2.amazonaws.com` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建 `glm-5` → 上游 `aws-global` / 上游模型 ID `zai.glm-5` / 协议覆盖 `openai` / 模型级 base_url `https://bedrock-runtime.us-west-2.amazonaws.com/openai/v1/`。

## OpenAI Images API

Protoflux 支持 OpenAI Images API，可用于图片生成和编辑，通过 `openai_images` 协议配合 `direct_path = true`。代理了两个端点：

- **`POST /v1/images/generations`** — 从文本提示词生成图片（JSON body）
- **`POST /v1/images/edits`** — 使用提示词和可选蒙版编辑图片（multipart/form-data，含文件上传）

当 `direct_path = true` 时，模型配置中的 `base_url` 被作为**完整的端点 URL** 使用 — 不会追加额外路径。这样可以配置 Azure OpenAI 或标准 OpenAI 端点的部署特定 URL。

### OpenAI 直连

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建 `openai-images` → 协议 `openai_images` / base_url `https://api.openai.com/v1` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建 `qwen-vl-max` → 上游 `openai-images` / 上游模型 ID `qwen-vl-max` / 协议覆盖 `openai_images` / direct_path `true` / 模型级 base_url `https://api.openai.com/v1/images/generations`。

### Azure OpenAI

Azure 要求使用带 API 版本查询参数的部署特定 URL：

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建 `azure-openai-images` → 协议 `openai_images` / base_url `https://{resource}.openai.azure.com/openai/v1` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建（或编辑）`qwen-vl-max` → 上游 `azure-openai-images` / 上游模型 ID `qwen-vl-max` / 协议覆盖 `openai_images` / direct_path `true` / 模型级 base_url `https://{resource}.openai.azure.com/openai/deployments/qwen-vl-max/images/generations?api-version=2025-04-01-preview`。

### Gemini (Imagen) 图片模型

Gemini 图片模型（`gemini-3-pro-image-preview`、Imagen 系列）使用与文本模型**相同的 `generateContent` 端点**——它们没有像 OpenAI `/v1/images/generations` 那样的独立图片 API。图片输出通过在 `generationConfig` 中设置 `responseModalities: ["IMAGE"]` 来请求。

为此类模型配置 `protocol = "google"`（而非 `openai_images`）。网关会自动在客户端 OpenAI Images 格式与 Gemini `generateContent` 格式之间进行翻译：

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建 `google-gemini` → 协议 `google` / base_url `https://generativelanguage.googleapis.com` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建 `gemini-3-pro-image-preview` → 上游 `google-gemini` / 上游模型 ID `gemini-3-pro-image-preview` / 协议覆盖 `google`。

客户端调用标准的 `POST /v1/images/generations`，使用 OpenAI 格式的请求体：

```json
{
  "model": "gemini-3-pro-image-preview",
  "prompt": "一只穿着宇航服的猫在火星上",
  "n": 1,
  "size": "1024x1024"
}
```

与 OpenAI 图片上游的关键区别：

| | OpenAI 图片上游 | Gemini 图片上游 |
|---|---|---|
| `protocol` | `openai_images` | `google` |
| `direct_path` | `true` | `false`（默认） |
| 上游端点 | `/v1/images/generations` | `/v1beta/models/{model}:generateContent` |
| 响应格式 | `{data: [{b64_json}]}` | `{candidates: [{content: {parts: [{inlineData}]}}]}` |
| 同一模型支持文本对话 | 否 | 是（通过 `POST /v1/chat/completions`） |

翻译由 `OpenAIImagesToGoogle` 翻译器（请求方向）和 `GoogleToOpenAIImages` 翻译器（响应方向）完成，已在协议翻译矩阵中注册。详见架构文档中的[协议翻译矩阵](architecture.zh-CN.md#协议翻译矩阵)。

#### 原生 Google 协议（透传）

客户端也可以直接使用**原生 Gemini 协议**。当调用 `POST /v1beta/models/{model}:generateContent`（或 `/v1/models/{model}:generateContent` 稳定别名）时，`client_protocol` 和 `target_protocol` 均为 `Google`——pipeline 解析为 identity（透传），不经过任何翻译步骤。请求体原样发送到上游。`/v1` 路径是客户端侧别名，上游 URL 始终构造为 `/v1beta/...`（v1 的严格超集，最大化模型兼容性）：

```json
POST /v1beta/models/gemini-3-pro-image-preview:generateContent
{
  "contents": [{
    "role": "user",
    "parts": [{ "text": "一只穿着宇航服的猫在火星上" }]
  }],
  "generationConfig": {
    "responseModalities": ["IMAGE"]
  }
}
```

这样客户端可以完全控制 Gemini 专有参数（安全设置、宽高比、生成数量等），而无需经过 OpenAI Images 翻译层。

### 请求参数

Images API 接受标准 OpenAI Images 参数：

| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `model` | string | 是 | 模型标识符（如 `qwen-vl-max`） |
| `prompt` | string | 是 | 图片的文本描述 |
| `n` | integer | 否 | 生成图片数量（默认：1） |
| `size` | string | 否 | 图片尺寸（如 `1024x1024`、`2048x2048`） |
| `quality` | string | 否 | 图片质量（`low`、`medium`、`high`、`auto`） |
| `response_format` | string | 否 | 响应格式（`png`、`jpeg`、`webp`） |
| `stream` | boolean | 否 | 启用流式输出（默认：false） |

### 流式输出

生成和编辑都支持通过 `stream: true` 启用流式输出。启用后，网关会将上游的 SSE 事件代理到客户端。

### 图片编辑（multipart）

`/v1/images/edits` 端点接受 `multipart/form-data`，包含：
- `image[]` — 一个或多个要编辑的图片文件（PNG/JPEG，每个 <50MB）
- `mask` — 可选的蒙版图片，需带 alpha 通道（PNG，与输入图片同尺寸）
- `prompt` — 编辑的文本描述
- `model`、`n`、`size`、`quality`、`response_format`、`stream` 等参数

网关会收集 multipart 数据，使用上游模型 ID 重建请求并转发到配置的端点。

## DashScope API

Protoflux 支持阿里云 DashScope API，使用 `protocol = "dashscope"`。不同类型的模型（文本生成、多模态、图像生成、视频生成、图像合成）有各自的端点，需要设置 `direct_path = true` 并将模型级别 `base_url` 配置为完整 URL。

### 端点参考

| API 类型 | 示例模型 | `base_url` 使用的完整 URL |
|---------|---------|--------------------------|
| 文本生成 | qwen-turbo, qwen-plus, qwen-max | `https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation` |
| 多模态生成 | qwen-vl-chat-v1, qwen-vl-plus | `https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation` |
| 图像生成（同步） | wan2.6-image | `https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation` |
| 图像生成（异步） | wan2.6-t2i | `https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation` |
| 视频生成 | wanx2.1-t2v, happyhorse | `https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis` |
| 图像合成 | wanx-v1 | `https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/synthesis` |
| 文本向量 | text-embedding-v4 | `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/api/v1/services/embeddings/text-embedding/text-embedding` |

### 配置示例

**文本生成（默认端点，无需 `direct_path`）**：

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建 `dashscope` → 协议 `dashscope` / base_url `https://dashscope.aliyuncs.com` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建 `qwen-turbo` → 上游 `dashscope` / 上游模型 ID `qwen-turbo` / 协议覆盖 `dashscope`。

**视频生成（自定义端点 + `direct_path`）**：

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建（或编辑）`dashscope` → 协议 `dashscope` / base_url `https://dashscope.aliyuncs.com` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建 `wanx2.1-t2v` → 上游 `dashscope` / 上游模型 ID `wanx2.1-t2v` / 协议覆盖 `dashscope` / direct_path `true` / 模型级 base_url `https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis`。

**文本向量（`kind` 选择端点，无需 `direct_path`）**：

文本向量模型使用百炼 MaaS 域名（`maas.aliyuncs.com`），与文本生成的 `dashscope.aliyuncs.com` 不同，须配置独立的 upstream。通过 `kind = "dashscope-text-embedding"` 选择端点路径，`base_url` 仅填域名根，网关按 `kind` 自动追加端点路径。

在 Console 里配置（不用手写 JSON）：

1. **上游** → 新建 `dashscope-maas` → 协议 `dashscope` / base_url `https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com` / api_keys `[{"key": "sk-...", "weight": 1}]`。
2. **模型** → 新建 `text-embedding-v4` → 上游 `dashscope-maas` / 上游模型 ID `text-embedding-v4` / 协议覆盖 `dashscope` / kind `dashscope-text-embedding`。

客户端用标准 OpenAI `/v1/embeddings` 协议发请求，网关自动翻译为 DashScope 文本向量格式（`input.texts`）并翻译响应回 OpenAI 格式。`encoding_format: "base64"` 在响应侧由翻译器编码为 f32 小端字节序列的 base64。`stream: true` 被拒绝（向量模型非流式）。

**重排序（OpenAI 兼容端点 + DashScope 翻译）**：

网关在 `POST /v1/rerank` 与 `POST /v2/rerank` 上提供 OpenAI 兼容的重排序接口（事实标准：`/v1/rerank` = Jina v1 / Cohere v1，`/v2/rerank` = Cohere v2）。两条上游路径：

- **DashScope 翻译**：模型 `protocol = "dashscope"` + `kind = "dashscope-rerank"`。客户端发 OpenAI/Cohere/Jina 重排序 body（`{model, query, documents:[str|{text}], top_n, return_documents?}`），网关翻译为 DashScope text-rerank 的**包装格式**（`{model, input:{query, documents:[string]}, parameters:{top_n?, return_documents?, instruct?}}`，`documents` 归一为字符串数组——扁平 body 会被 DashScope 以 `Field required: input.query & input.documents` 拒绝），并将响应翻译回**同时携带两种计费形状**的客户端超集：`{id, model, results:[{index, relevance_score, document?}], usage:{prompt_tokens, total_tokens}, meta:{billed_units:{input_tokens, output_tokens, search_units}, tokens:{input_tokens, output_tokens}}}`（按 `relevance_score` 降序）。Jina/vLLM 客户端读 `usage`，Cohere v1/v2 客户端读 `meta.billed_units`/`meta.tokens`。`v1` 的 string documents 与 `v2` 的 `{text}` 对象均接受；`rank_fields`、`max_chunks_per_doc`、`max_tokens_per_doc` 丢弃（DashScope text-rerank 无等价字段）。
- **OpenAI 兼容上游**：模型 `protocol = "openai_rerank"`，走专属 `OpenAIRerankExecutor`，请求 body 与响应均原样透传（同协议 identity 翻译），executor 向 `base_url` 追加 `/rerank`（`direct_path = true` 则 `base_url` 为完整 URL）。适用于 vLLM / Cohere / Jina / 硅基流动等原生兼容上游。要指定版本，把版本前缀放进 `base_url`——executor 追加 `/rerank`，故 `base_url = ".../v1"` 得 `/v1/rerank`、`base_url = ".../v2"` 得 `/v2/rerank`；非标准路径用 `direct_path = true` 配完整 URL。

重排序为非流式；`stream: true` 被拒绝。

`/v1/rerank` 与 `/v2/rerank` 并不作为两个独立协议处理——两者都解析到同一个 `OpenAIRerank` 协议，无基于路径的分支。两者的内容确有差异（v2 增加了 `{text}` 对象 documents、`rank_fields`、以及响应中对象形态的 `document`），但网关将其合并：DashScope 翻译器同时接受 v1 字符串数组和 v2 `{text}` 对象数组，并产出 v2 规范的超集响应（`document` 为 `{text}` 对象，同时携带 `usage` 与 `meta`）；在 `openai_rerank` 路径上 body 原样透传，但 executor 始终向 `base_url` 追加 `/rerank`——客户端的 `/v1` 或 `/v2` 路径不会被转发，故上游版本由 `base_url` 中的版本前缀选定（或 `direct_path = true` 配完整 URL），而 `rank_fields` 等 v2 独有请求字段需要支持 v2 的上游才能处理。

- **DashScope 翻译路径**（百炼模型如 gte-rerank）：**模型** → 新建 `gte-rerank` → 上游 `dashscope-maas` / 上游模型 ID `gte-rerank` / 协议覆盖 `dashscope` / kind `dashscope-rerank`。
- **OpenAI 兼容上游路径**（vLLM/Cohere/Jina）：**模型** → 新建 `bge-reranker-v2-m3` → 上游 `vllm-rerank` / 上游模型 ID `BAAI/bge-reranker-v2-m3` / 协议覆盖 `openai_rerank` / direct_path `false`。

客户端调用：

```bash
curl -X POST http://localhost:7890/v1/rerank \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gte-rerank",
    "query": "capital of France",
    "documents": ["Paris", "London", "Berlin"],
    "top_n": 2
  }'
```

DashScope 原生客户端仍可直接发 `POST /v1/services/{*rest}`（DashScope 原生 body），与上述 OpenAI 端点共用同一 `dashscope-rerank` 模型。

**语音转写（多模态，OpenAI 兼容）**：

`qwen-audio` 等音频→文本多模态模型使用同步多模态端点（`multimodal-generation/generation`），通过 `kind = "dashscope-multimodal"` 选择端点路径。该 kind 与下方 `dashscope-audio-asr`（异步 paraformer）不同 —— 此处为同步多模态，且与 OpenAI `/v1/audio/transcriptions` 双向兼容。`base_url` 仅需域名根，executor 按 `kind` 拼接端点路径，故复用文本生成所用的 `dashscope` upstream。

配置为该 kind 的模型既可作 DashScope 原生多模态上游，也可作 OpenAI 音频转写上游：

- 正向：客户端发 OpenAI `/v1/audio/transcriptions`（multipart `file` 上传）→ 网关将音频 base64 内联为 DashScope 多模态 JSON（`content: [{audio: "data:<ct>;base64,..."}, {text: 转写指令}]`）→ 调 DashScope executor → 将 `{output:{choices:[{message:{content}}]}}` 转回 OpenAI `{text}`。`prompt`/`language` 字段注入到转写指令。
- 反向：DashScope 客户端发多模态 JSON → 网关从 `content` 提取 base64 音频 → 构造 OpenAI multipart 转写请求 → 调 OpenAI Audio（whisper）executor → 将 `{text}` 包装回 DashScope 响应。反向仅接受 base64 内联音频，URL 输入报 `unsupported_feature`。

`/v1/audio/speech`（文本转语音）对 DashScope 上游不支持 —— DashScope 多模态仅音频→文本，文本→语音会经 pipeline 报 `unsupported_feature`。

在 Console 里配置：**模型** → 新建 `qwen-audio` → 上游 `dashscope` / 上游模型 ID `qwen-audio` / 协议覆盖 `dashscope` / kind `dashscope-multimodal`。

客户端即可用标准 OpenAI `/v1/audio/transcriptions` multipart 上传音频，网关自动翻译到 DashScope 多模态格式并转回 `{text}`。

**同步语音转写（`fun-asr-flash`，OpenAI 兼容）**：

`fun-asr-flash` 等同步 ASR 模型与 `qwen-audio` 共用多模态端点（`multimodal-generation/generation`），配置上**直接使用 `kind = "dashscope-multimodal"`** —— `kind` 只决定端点 URL，不决定请求体结构。与下方 `dashscope-audio-asr`（异步 paraformer，需轮询任务）不同，`fun-asr-flash` 是同步调用，直接返回转写文本，无需轮询。`base_url` 仅需域名根，executor 按 `kind` 拼接端点路径，故复用文本生成所用的 `dashscope` / `dashscope-maas` upstream。

请求体结构由**客户端请求的协议端点**决定，而非由 `kind` 决定：当客户端走 OpenAI `/v1/audio/transcriptions`（multipart `file` 上传）时，网关将音频 base64 内联为 OpenAI 风格的 `input_audio` 内容块（`content: [{type:"input_audio", input_audio:{data:"data:<ct>;base64,..."}}]`），并在 `parameters.format` 中填入按 content-type 推导的容器格式（`audio/mpeg` → `mp3`，`audio/wav` → `wav` 等）→ 调 DashScope executor → 从 `output.text`（回退 `output.sentence.text`）提取转写文本 → 返回 OpenAI `{text}`。

> 与 `qwen-audio` 的区别仅在**请求/响应结构**，不在 `kind`：二者 `kind` 均为 `dashscope-multimodal`、端点相同；但 `qwen-audio` 走 DashScope 原生客户端时用 `{"audio": ...}` 内容块 + 文本指令、响应读 `output.choices[0].message.content`，而 `fun-asr-flash` 走 OpenAI `/v1/audio/transcriptions` 时用 `input_audio` 块 + `parameters.format`、响应读 `output.text`。结构差异由客户端端点驱动，无需另设 `kind`。

在 Console 里配置：**模型** → 新建 `fun-asr-flash` → 上游 `dashscope-maas` / 上游模型 ID `fun-asr-flash-2026-06-15` / 协议覆盖 `dashscope` / kind `dashscope-multimodal`。

客户端即可用标准 OpenAI `/v1/audio/transcriptions` multipart 上传音频，网关按客户端端点自动构造 `fun-asr-flash` 的 `input_audio` 格式并转回 `{text}`。

**语音 ASR 转写（`kind` 选择端点 — 异步，仅 DashScope 原生）**：

语音 ASR 为异步。通过 `kind = "dashscope-audio-asr"` 选择端点路径，并设 `extra_config.dashscope.async_mode = true`，Executor 会自动注入 `X-DashScope-Async: enable` 头。OpenAI 无 ASR 标准协议，客户端以 DashScope 原生格式发往 `POST /v1/services/{*rest}`，并轮询 `GET /v1/services/{model}/tasks/{task_id}`。

在 Console 里配置：**模型** → 新建 `fun-asr` → 上游 `dashscope-maas` / 上游模型 ID `fun-asr` / 协议覆盖 `dashscope` / kind `dashscope-audio-asr` / extra_config `{"dashscope": {"async_mode": true}}`。

### 客户端端点与协议翻译

Protoflux 为 DashScope 客户端提供了专用端点，以支持同步和异步操作：

| 客户端请求 | 说明 | 翻译模式 |
|-----------|------|----------|
| `POST /v1/services/{*rest}` | DashScope 统一服务端点（文本、多模态、图像、视频、文本向量、重排序、语音 ASR）。异步模式通过模型配置的 `extra_config.dashscope.async_mode` 控制。 | 直连（透传） |
| `GET /v1/services/{model}/tasks/{task_id}` | 轮询异步任务状态。代理到 DashScope 任务查询 API。 | 直连（透传） |
| `POST /v1/chat/completions` | 发送 OpenAI 格式请求到 DashScope 模型。 | 翻译模式 |

当客户端使用 `/v1/chat/completions` 请求 DashScope 模型时，网关会自动将 OpenAI Chat Completions 负载翻译为 DashScope 原生格式，并在响应时通过 `OpenAIChatCompletionsToDashScope` 翻译器转回 OpenAI 格式。

### 异步模式

对于仅支持异步的模型类型（如视频生成、图像合成、语音 ASR），客户端必须向 `POST /v1/services/{*rest}` 统一端点提交请求。
除了请求正确的端点，还需要在模型配置中设置 `extra_config.dashscope.async_mode = true`。这指示 Executor 处理异步模式的特殊 HTTP 头（网关会自动向上游请求添加 `X-DashScope-Async: enable`）。

任务提交后，网关会立即返回 `{task_id, task_status}`。客户端之后需要轮询 `GET /v1/services/{model}/tasks/{task_id}` 直到状态变为 `SUCCEEDED` 或 `FAILED`。

## 重试策略

当上游请求因可重试错误失败时（HTTP 5xx、连接超时、读取超时），网关会自动以指数退避进行重试。两个独立的限制条件共同控制重试行为：

| 字段 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `max_upstream_retries` | u32 | 3 | 每个请求在所有凭证间的最大重试次数 |
| `max_key_rotations` | u32 | 3 | 最多轮换多少个不同的上游密钥。`0` 表示不限制 |

### 工作原理

1. 请求因可重试错误失败。
2. 网关选取下一个可用凭证（如果 upstream 配置了多个 key），并等待指数级增长的延迟（`500ms × 2^attempt`，上限 5 分钟）。
3. 重试持续到 `max_upstream_retries` 或 `max_key_rotations` 任一耗尽为止。
4. 如果 `max_key_rotations` 先达到上限，即使 `max_upstream_retries` 还有剩余，网关也会直接返回最后一次错误。

### 配置示例

**默认行为**（重试 3 次，不限密钥轮换）：
```toml
[retry]
max_upstream_retries = 3
max_key_rotations = 0
```

**保守策略**（仅重试 2 次，最多试 2 个不同密钥）：
```toml
[retry]
max_upstream_retries = 2
max_key_rotations = 2
```

**完全禁用重试**：
```toml
[retry]
max_upstream_retries = 0
```

### 使用场景

- **多密钥高可用 upstream**：保持 `max_key_rotations = 0`，让每个密钥都有机会被尝试。单个坏密钥不会耗尽重试预算，因为重复使用的密钥会被去重。
- **密钥池很大但对延迟敏感**：限制 `max_key_rotations`，避免在大量密钥中耗费过多轮换时间。
- **非幂等操作**（如图片生成）：建议设置 `max_upstream_retries = 0`，避免重复计费或产生重复副作用。

### 流式响应限制

流式响应的重试仅在**发送第一个 SSE 数据块之前**尝试。一旦客户端开始接收数据，就不支持中途重试，因为已经发送的数据块无法回滚。

## DNS 解析器健康（DoH/DoT 重建）

每个 upstream 的 DoH/DoT 解析器（通过该 upstream 的 `dns_servers` 配置）在 `lookup_ip` 跨过持续失败阈值时被重建：抖动或轮换的 Anycast IP 集合被重新解析为全新集合、探测验证后原位热替换。完整设计见[架构](architecture.zh-CN.md)的「上游 DNS 解析器」一节。

### 工作原理

resolver 在每次 `lookup_ip` 失败时递增失败计数（不被 stale-cache fallback 掩盖），在每次成功时清零。`ResolverHealthMonitor` 按 resolver key 监视计数，跨阈值且在 cooldown 外时，以 singleflight 启动一个 `spawn_blocking` 重建：重新解析 DoH/DoT hostname、探测、原位覆盖缓存中的 resolver（无 MISS 窗口；旧 resolver 持续服务在飞请求直到被替换）。验证失败的重建保留旧 resolver 并进入 cooldown。

### 配置字段

```toml
[dns_health]
rebuild_threshold = 5       # 连续 lookup_ip 失败触发重建
rebuild_cooldown_secs = 120 # 同一 resolver 最小重建间隔
rebuild_timeout_secs = 30   # 单次重建预算（re-resolve + probe）
```

| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `rebuild_threshold` | u32 | `5` | 连续 `lookup_ip` 失败次数达到此值触发 resolver 重建 |
| `rebuild_cooldown_secs` | u64 | `120` | 同一 resolver key 最小重建间隔（秒）。DoH server hostname 自身有 DNS TTL，更频繁重建无意义 |
| `rebuild_timeout_secs` | u64 | `30` | 单次重建（re-resolve + probe）墙钟预算（秒）。超时则释放 singleflight 标志并进 cooldown，self-heal 不被卡死的系统解析器钉死 |

这些字段构造期读入并克隆进 resolver health monitor，与多数基础设施参数一样**变更需重启**（值不热重载进 live monitor）。

## 配置来源

Protoflux 从 `--config` TOML 文件加载基础设施配置。TOML 值中的 `${VAR}` 引用在启动时从进程环境展开，并自动转换为目标字段类型（见[环境变量](#环境变量)）。**不存在**带前缀的环境变量覆盖层——只有 TOML 文件中实际出现的 `${VAR}` 引用会被展开；文件未引用的环境变量不产生任何作用。

此外，**业务配置**（access_keys, upstreams, models, access_key_groups）从数据库（SQLite 或 PostgreSQL）加载。SQLite 模式下，30 秒轮询循环检测变更并热重载。PostgreSQL 模式下，多个网关实例通过相同的轮询机制共享配置变更。

## 环境变量

### `${VAR}` 文件内展开

在 TOML 字符串值中直接写 `${VAR}` 引用。网关启动时替换为环境变量值。

```toml
# server.docker.toml
[server]
port = "${PORT}"
storage_mode = "${STORAGE_MODE}"
```

**规则：**
- 必须使用花括号：`${VAR}` — 裸 `$VAR` **不会**被展开，以避免与 bcrypt 哈希（`$2y$12$...`）等 `$` 前缀值冲突
- 变量名是任意的 — TOML 文件中引用什么就查找什么
- **自动类型转换**：当整个值是一个 `${VAR}` 引用时，结果会自动转换为目标字段类型：
  - `""`（空）→ `null` → 用于 `Option<T>` 字段如 `secret_key`、`postgres_url`（表示不启用）
  - `"8080"` → 整数 → 用于 `port`、`max_failures` 等
  - `"true"` → 布尔 → 用于 `enabled`、`allow_remote` 等
  - `"50.0"` → 浮点 → 用于 `max_request_size_mb` 等
  - 其他 → 字符串 → 用于 `host`、`storage_mode` 等

这是 `server.docker.toml` 采用的方式，每个字段都引用一个环境变量。你可以挂载自定义的 TOML 文件来配合容器化部署。

## 建议实践

- 生产密钥不要提交到仓库。
- 不同运维策略的 upstream 建议拆分成独立的 upstream。
- 对任何公网或多人共享部署，建议使用结构化的 `access_keys` 和 `access_key_groups` 替代传统的 `api_keys`。
- 如果网关暴露在公网，请将 `web_console.allow_remote` 设为 `false`，通过 SSH 隧道或带 IP 白名单的反向代理访问控制台。

## 请求 ID 关联

每个进入网关的请求都会获得一个唯一的 `Protoflux-Request-ID` 头，格式为 `<iso8601>-<uuid4>`（例如 `20260424T083015Z-a1b2c3d4-...`）。该 ID 会：

- 在请求进入处理流程前注入到请求头中
- 镜像到响应头中返回给客户端
- 作为额外头转发给上游提供商
- 通过 `tracing` span 嵌入所有结构化日志条目

在日志中搜索 `request_id="..."` 即可查看单个请求的完整生命周期。

## 报文日志

每个请求产生一条**统一的 `info` 级别日志事件**，包含完整的请求生命周期——客户端请求、上游请求/响应、错误详情。Body 捕获在最外层中间件层完成，确保即使早期失败（401 未授权、429 限流）也包含请求 body。

```toml
[logging]
level = "info"                    # info 级别即可——body 捕获在所有级别都生效
max_body_size_mb = 25             # 每个 body 的最大兆字节数（终端 + SSE/webconsole）
persist_request_logs = true       # 是否持久化到数据库
log_retention_days = 7            # 日志保留天数（0 = 禁用自动清理）

# SSE 流式响应体磁盘溢出（工业级）
stream_body_max_disk_mb = 256     # body segment 文件最大磁盘占用（MB）
stream_body_batch_size_kb = 64  # writer batch 大小（KB）
stream_body_linger_ms = 5         # batch 最长等待时间（毫秒）
stream_body_channel_capacity_chunks = 2048  # mpsc channel 容量（chunks 数，非字节）
```

| 字段 | 说明 |
|------|------|
| `max_body_size_mb` | 所有日志（终端 + SSE/webconsole）中 body 截断的最大兆字节数（默认 25） |
| `persist_request_logs` | 是否将请求日志持久化到数据库（默认 true） |
| `log_retention_days` | 数据库中日志保留天数，0 表示禁用自动清理（默认 7） |
| `stream_body_max_disk_mb` | SSE 流式 body 磁盘溢出上限（默认 256 MB），超限后 chunk 被丢弃 |
| `stream_body_batch_size_kb` | writer task batch 大小（默认 64 KB），攒满后 flush 到磁盘 |
| `stream_body_linger_ms` | batch 不满时最长等待时间（默认 5 ms） |
| `stream_body_channel_capacity_chunks` | mpsc channel 容量，chunks 数（默认 2048），满时 chunk 被丢弃（非阻塞） |

统一日志事件包含以下字段：

- `request_headers` — 客户端请求头（敏感值脱敏）
- `request_body` — 客户端请求 body（在任何下游中间件之前捕获）
- `upstream_url` — 发送给上游提供商的 URL
- `upstream_request_headers` / `upstream_request_body` — 转换后发送给上游的请求
- `upstream_response_status` / `upstream_response_headers` / `upstream_response_body` — 上游原始响应
- `error` — 4xx/5xx 时包含响应 body 内容（如 `"Missing or invalid API key"`）

**Web Console 日志**（SSE）：当 Console 的 Live 日志开关打开时，**所有请求**都会广播到 Web Console——包括 401/429 早期失败、内部端点（`/v1/models`、`/healthz`）、Console 路由。未认证请求显示 `user: "anonymous"`。

**错误详情提取**：对于非流式 4xx/5xx 响应，响应 body 被消费以提取错误消息，然后重新组装以便正常发送给客户端。截断至 512 字节。

流式响应的上游 body 通过后台任务在流结束后记录，避免中断流。

**安全警告**：报文中可能包含敏感数据（PII、提示词中的密钥、生成的内容）。仅在调试时启用，调试完成后请关闭。

## Prometheus 指标

Protoflux 在 `/metrics` 端点暴露 Prometheus 指标，用于监控和告警。此端点使用拉取式抓取，兼容 Prometheus server 和其他指标收集系统。

### 认证

`/metrics` 端点需要 Bearer Token 认证，出于安全考虑**默认禁用**：

| 配置 | 行为 |
|------|------|
| `metrics_auth_token` 未设置或为空 | 返回 `403 Forbidden`，消息为 "Metrics endpoint disabled" |
| 请求缺少 `Authorization: Bearer <token>` | 返回 `401 Unauthorized` |
| 请求使用错误的 token | 返回 `401 Unauthorized`，带 `WWW-Authenticate: Bearer realm="metrics"` 头 |
| 请求使用正确的 token | 返回 Prometheus 文本格式的指标 |

**配置示例：**

```toml
[server]
metrics_auth_token = "your-secure-token-here"
```

**使用 Prometheus 抓取：**

```yaml
# prometheus.yml
scrape_configs:
  - job_name: 'protoflux'
    scrape_interval: 15s
    bearer_token: 'your-secure-token-here'
    static_configs:
      - targets: ['protoflux:7890']
```

### 可用指标

| 指标 | 类型 | 标签 | 说明 |
|------|------|------|------|
| `protoflux_requests_total` | Counter | `method`, `path`, `status` | 处理的 HTTP 请求总数 |
| `protoflux_request_duration_seconds` | Histogram | `method`, `path` | 请求延迟分布 |
| `protoflux_requests_in_flight` | Gauge | `method` | 当前活跃请求数 |
| `protoflux_upstream_errors_total` | Counter | `upstream`, `error_type` | 上游提供商失败次数 |
| `protoflux_tokens_total` | Counter | `direction`, `model` | Token 使用量（direction: prompt/completion） |
| `protoflux_active_streams` | Gauge | — | 当前活跃的 SSE 流数量 |
| `protoflux_stream_semaphore_available` | Gauge | — | 可用的流并发槽位 |

**查询示例（PromQL）：**

```promql
# 按状态码统计请求速率
rate(protoflux_requests_total[5m])

# P95 延迟
histogram_quantile(0.95, rate(protoflux_request_duration_seconds_bucket[5m]))

# 错误率
sum(rate(protoflux_requests_total{status=~"5.."}[5m])) / sum(rate(protoflux_requests_total[5m]))
```

## 健康检查

Protoflux 提供两个健康检查端点，用于 Kubernetes probes 和负载均衡器：

### 存活探针（`/health`, `/healthz`）

始终返回 `200 OK`。用于 Kubernetes `livenessProbe`，检测死锁或挂起的进程。

```yaml
livenessProbe:
  httpGet:
    path: /healthz
    port: 7890
  initialDelaySeconds: 10
  periodSeconds: 10
```

### 就绪探针（`/ready`）

当网关准备好接收流量时返回 `200 OK`，未准备好时返回 `503 Service Unavailable`。检查项（均为实例本地状态）：

1. **排空模式未激活**：不在优雅关闭预停阶段
2. **数据库健康**：存储后端（SQLite/PostgreSQL）可访问

零上游配置刻意不使就绪探针失败：`upstreams` 是跨实例共享的全局业务配置，瞬时空状态（重载窗口、运维清空）若触发就绪检查，会让所有实例同时 503 并摘除整个 target group。零上游在请求层解析为 `NoCredentials` → `503`。

用于 Kubernetes `readinessProbe`，控制流量路由：

```yaml
readinessProbe:
  httpGet:
    path: /ready
    port: 7890
  initialDelaySeconds: 5
  periodSeconds: 5
  failureThreshold: 3
```

**响应格式：**

```json
// 200 OK（就绪：非排空且数据库可达）
{
  "status": "ready",
  "checks": {
    "drain_mode": false,
    "database": true
  }
}

// 503 Service Unavailable（排空中：优雅关闭预停阶段，跳过数据库探测）
{
  "status": "not_ready",
  "reason": "drain_mode",
  "message": "Pod is draining, removing from load balancer"
}

// 503 Service Unavailable（数据库不可达）
{
  "status": "not_ready",
  "checks": {
    "drain_mode": false,
    "database": false
  }
}
```

### Kubernetes Pre-Stop Drain

在 Kubernetes 中运行时，配置 `pre_stop_delay_secs` 以确保优雅关闭，不丢弃进行中的请求：

```toml
[server]
pre_stop_delay_secs = 15
graceful_shutdown_timeout_secs = 30
```

**关闭流程：**

1. Kubernetes 向 Pod 发送 `SIGTERM`
2. Protoflux 将 `/ready` 设置为返回 `503`（drain 模式）
3. 休眠 `pre_stop_delay_secs`（允许 kube-proxy 从 Service endpoints 中摘除 Pod）
4. 停止接受新连接
5. 等待最多 `graceful_shutdown_timeout_secs` 让进行中的请求完成
6. 退出

**Kubernetes 部署示例：**

```yaml
spec:
  terminationGracePeriodSeconds: 60  # pre_stop_delay + graceful_shutdown_timeout + 缓冲
  containers:
    - name: protoflux
      lifecycle:
        preStop:
          exec:
            command: ["/bin/sh", "-c", "sleep 1"]  # 小延迟确保 SIGTERM 被处理
```

## 每 IP 限速

除了基于 API key 的每用户限速外，Protoflux 还支持每 IP 限速，用于防御滥用和 DDoS 攻击。

### 配置

```toml
[server]
ip_rate_limit_rpm = 120              # 每 IP 每分钟 120 个请求
trusted_proxies = "10.0.0.0/8"       # 信任来自这些 CIDR 的 X-Forwarded-For
```

### 工作原理

- **滑动窗口算法**：每个 IP 有一个计数器，60 秒后重置
- **IP 提取**：
  - 如果配置了 `trusted_proxies` 且请求来自受信任的代理，则使用 `X-Forwarded-For` 中的第一个 IP
  - 否则，使用直连 IP
- **响应**：被限速时，返回 `429 Too Many Requests`，带 `Retry-After` 头
- **作用域**：在认证之前应用，因此可以防御对 API key 的暴力破解攻击

### 使用场景

| 场景 | 配置 |
|------|------|
| 公开 API 网关 | `ip_rate_limit_rpm = 60` 防止滥用 |
| 在 Cloudflare/CDN 后面 | 将 `trusted_proxies` 设置为 Cloudflare 的 IP 范围 |
| 内部服务网格 | 禁用（不设置 `ip_rate_limit_rpm`）或设置高限制 |
| 多区域部署 | 通过 Redis 在所有区域使用一致的限制 |

**注意**：每 IP 限速是对每用户限速的补充。两者可以同时启用。每用户限制更细粒度（按 API key），而每 IP 限制可以防御 key 枚举攻击。
{% endraw %}
