ScootGate
ScootGate 是独立部署、默认拒绝(fail-closed)的 AI Provider API 网关。 它消费控制面下发的授权与路由快照,强制所有 Provider 流量经上游代理出站, 并提供 OpenAI 与 Anthropic 兼容入口。
项目优先保证授权、出口与计费副作用边界正确:
- 鉴权、代理出口或 Provider TLS 校验失败时不回落直连;
- 请求可能到达 Provider 后不自动重试;
- 客户端凭据、Provider 凭据和请求正文不进入日志或用量事件;
- 路由快照校验后原子生效,坏版本保留最后一份有效配置。
本站内容
本站只发布面向使用者和集成方的稳定文档:
- 功能手册:安装启动、配置、模型路由、故障隔离、用量 outbox 和运维;
- 完整场景:可复制的 static 与 snapshot 生产部署;
- 协议规范:客户端 HTTP API、控制面快照、MQTT 用量事件和健康接口。
各页面以可观察行为和稳定集成契约为边界,不把实现过程作为软件使用契约。
从哪里开始
本地预览
安装 mdBook 后,在仓库根目录运行:
mdbook serve --open
快速开始
ScootGate 不是单机模拟 Provider:启动前必须准备授权快照控制面、强制出站代理、 Provider 凭据和 MQTT broker。本章给出最短可用路径;生产部署应继续阅读 生产最佳实践。
1. 获取程序
从 GitHub Release 下载对应平台的压缩包,或使用 Rust 1.88+ 从源码构建:
cargo build --release --locked
二进制只接受一个配置参数:
scootgate --config /path/to/scootgate.toml
也可以使用短参数 -c。
2. 选择路由模式
| 模式 | 配置来源 | 适用场景 |
|---|---|---|
static(默认) | 本地 [backends] 与 [models] | 单节点、路由变化少 |
snapshot | 控制面版本化路由快照 | 多节点、集中热更新 |
首次部署建议从经过测试的完整示例开始:
两种模式严格互斥,不能把本地路由与远端快照合并。
3. 准备依赖
至少需要:
- 授权快照端点:按控制面快照协议 返回客户端 API key 的 SHA-256 摘要;
- 出站代理:支持 HTTP CONNECT 或 SOCKS5,并为 ScootGate 分配专用身份;
- Provider 凭据:由环境变量或只读文件提供;
- MQTT broker:接收用量事件并发送快照更新通知;
- 持久目录:保存授权/路由缓存和用量 outbox;
- 生产 TLS 证书:配置到
[server.tls]。
4. 写入配置
开发环境可以从仓库示例开始:
cp scootgate.example.toml scootgate.toml
必须替换所有 REPLACE_WITH_... 占位符,并设置真实控制面、代理、Provider、
MQTT 和文件路径。生产环境不要提交渲染后的配置。
Static 模式至少包含:
instance_id = "gate-a"
[server]
listen = "127.0.0.1:8081"
[auth]
snapshot_url = "https://control.example.com/auth-snapshot"
token = "REPLACE_WITH_CONTROL_TOKEN"
[egress]
kind = "http_connect"
addr = "proxy.internal.example.com:8443"
username = "gate-a"
password = "REPLACE_WITH_PROXY_PASSWORD"
tls = true
[credentials.provider]
file = "/run/secrets/provider-api-key"
[backends.provider]
protocol = "openai_compatible"
upstream = "https://api.example.com"
credential_ref = "provider"
[models.chat]
protocol = "openai_compatible"
targets = [{ backend = "provider", model = "real-model" }]
[mqtt]
broker = "mqtts://mqtt.internal.example.com:8883"
完整字段和默认值见配置参考。
5. 启动并等待就绪
./scootgate --config scootgate.toml
启动日志报告 listener 已绑定后,继续检查独立 health listener:
curl --fail http://127.0.0.1:9091/readyz
只有返回 200 才能接入流量。无授权快照或 snapshot 路由尚未加载时会返回
503,这是 fail-closed 的预期行为。
6. 验证客户端 API
列出公共 OpenAI-compatible 模型:
curl --fail \
-H 'Authorization: Bearer YOUR_SCOOTGATE_CLIENT_KEY' \
http://127.0.0.1:8081/v1/models
发送聊天请求:
curl --fail \
-H 'Authorization: Bearer YOUR_SCOOTGATE_CLIENT_KEY' \
-H 'Content-Type: application/json' \
-d '{"model":"chat","messages":[{"role":"user","content":"ping"}]}' \
http://127.0.0.1:8081/v1/chat/completions
生产 TLS 场景应使用 https:// 并验证配置证书的信任链,不要使用 -k。
7. 验证安全边界
上线前至少执行:
- 使用未知或吊销 key,请求必须返回
401; - 停止出站代理,请求必须失败且 Provider 不得收到直连;
- 使用未知模型,请求必须在出网前失败;
- 停止 MQTT,API 继续服务且 outbox 积压增长;
- 向 snapshot 控制面发布非法版本,当前路由不得被覆盖。
完整验收步骤见生产最佳实践。
功能与行为
本章描述 ScootGate 当前提供的软件能力及其外部可观察行为。
客户端入口
ScootGate 提供三个需要 API key 的 HTTP 入口:
| 方法 | 路径 | 功能 |
|---|---|---|
POST | /v1/chat/completions | OpenAI-compatible 聊天与 SSE |
POST | /v1/messages | Anthropic Messages 与 SSE |
GET | /v1/models | 列出公共 OpenAI-compatible 模型 |
客户端使用公共模型名,ScootGate 在转发前把 model 改写为 target 的真实
Provider 模型名。未知模型、协议不匹配或能力不足都会在出网前拒绝。
精确请求、响应和错误格式见客户端 HTTP API。
授权
- 接受
Authorization: Bearer <key>或x-api-key: <key>; - 两者同时存在时 Bearer 优先;
- 控制面快照只包含原始 key 的 SHA-256 摘要;
- 未加载有效授权快照时所有客户端 key 都被拒绝;
- 新版本快照原子替换旧版本,吊销在下一份有效快照生效;
- 原始 key 不写入缓存、日志、MQTT 或用量 outbox。
ScootGate 不创建用户、租户、套餐或 API key;这些属于外部控制面。
强制出站代理
每个 Provider 连接只能经配置的 HTTP CONNECT 或 SOCKS5 代理建立。代理身份 是 ScootGate 专用服务身份,客户端 key 不会复制到代理或 Provider。
- 代理连接失败:返回网关错误;
egress.tls = true:验证代理证书;- HTTPS Provider:始终验证 Provider 证书;
- 不存在直连回落或跳过证书校验开关;
- 明文 Provider upstream 只允许 loopback 开发地址。
公共模型与 target
[models.<name>] 定义客户端可见模型;每个 target 绑定:
- backend ID;
- Provider 真实模型名;
- 相对权重;
- stream、tools、JSON schema 能力。
能力由请求体推导。需要某项能力的请求只在声明支持该能力的 target 中选择。 OpenAI-compatible 和 Anthropic Messages 使用独立协议池,不做隐式转换。
负载均衡
默认 weighted_p2c 从候选 target 中进行加权采样,再按以下信号选择:
- 相对权重;
- backend 本地在途数及容量;
- EWMA 首包延迟;
- 新 backend 慢启动。
weighted_random 是仅考虑权重的简化降级算法。权重表达相对选择意图,
不保证小样本严格百分比分配。
每个节点独立维护热状态,不依赖中心数据库、分布式锁或节点共识。
容量与故障隔离
每个 backend 具有节点本地 max_inflight_per_node 上限。达到上限后不再接受
新选择,其他健康 target 仍可接流。
传输故障按发送状态处理:
- DefinitelyNotSent:能证明请求字节未发出,可在总尝试上限内改选;
- PossiblySent:请求可能到达 Provider,绝不自动重试;
- ResponseReceived:已收到 Provider 响应。
连续传输失败触发熔断;冷却后只允许一个 half-open 探测。Provider 401/403 会停用整个 backend,429 原样返回并触发短暂冷却。
路由来源
Static
backends/models 随本地 TOML 加载,启动时完整校验。修改需要发布配置并重启进程。
Snapshot
backends/models 由控制面完整下发:
- schema 固定为 1;
- 版本必须严格递增;
- 未知 credential 引用、非法 URL、空模型池等会整体拒绝;
- 有效快照编译后原子替换;
- 请求在开始时固定一份路由表,热更新不影响在途请求;
- backend 身份未变时继承 EWMA、熔断、在途和停用状态;
- 控制面超期不可达时节点转 unready,但最后有效表继续服务。
用量事件与 outbox
每个完成的 Provider 路由生成 schema 2 非权威事件,包含租户/key 标识、 公共模型、真实模型、backend、发送状态、失败阶段、字节数、首包延迟和 Provider 原生 token usage。
事件先原子写入有界磁盘 outbox,再以 MQTT QoS 1 投递;收到 PUBACK 后删除。
断线或重启会使用稳定 event_id 重放,接收方必须去重。
outbox 满或不可写时按 drop_newest 丢弃新事件并计数,API 数据面继续服务。
该通道固定 authoritative = false,不能作为权威计费账本。
TLS、健康与停机
- API listener 可原生终止 TLS 1.2+;生产必须配置 TLS;
- health listener 独立于 API listener,不要求 API key;
/healthz表示进程存活;/readyz同时要求授权快照、路由就绪且节点未排空;- SIGINT/SIGTERM 后节点进入 draining,停止接收新连接并在 grace period 内 等待在途请求完成,超时后有界退出。
明确不提供的功能
- 用户、租户、套餐或 API key 管理控制面;
- Provider 直连回落;
- 可能已发送请求的自动重试、对冲或多 backend 竞速;
- 任意规则 DSL、脚本路由或跨协议转换;
- 权威计费账本;
- 公共 API 上的管理端点。
配置参考
ScootGate 使用 TOML 配置,并对所有结构启用未知字段拒绝。拼写错误、未知引用、 非法 URL 或越界参数会使进程启动失败,不会静默采用近似配置。
完整可运行结构:
顶层
| 字段 | 必填 | 说明 |
|---|---|---|
instance_id | 是 | 节点稳定标识,不能为空 |
server | 是 | 客户端 API listener |
health | 否 | 独立健康 listener |
auth | 是 | 授权快照控制面 |
egress | 是 | 唯一 Provider 出口代理 |
api | 否 | API 大小与超时边界 |
routing | 否 | 负载与熔断参数 |
routing_source | 否 | static 或 snapshot |
credentials | 视模式 | Provider 凭据引用 |
backends / models | static 必填 | 本地路由数据 |
usage | 否 | 非权威磁盘 outbox |
mqtt | 是 | 用量和快照通知 |
shutdown | 否 | 有界停机 |
log | 否 | JSON 日志级别 |
[server] 与 [server.tls]
| 字段 | 默认值 | 约束 |
|---|---|---|
listen | 无 | 必填 IP:port |
max_concurrent_requests | 1024 | 1..=16384 |
request_head_timeout_ms | 5000 | 1..=60000,同时约束 TLS 握手 |
tls.cert | 无 | PEM 证书链路径 |
tls.key | 无 | PEM 私钥路径 |
生产 API listener 必须配置 TLS。未配置时为明文开发模式。
[health]
| 字段 | 默认值 | 约束 |
|---|---|---|
listen | 127.0.0.1:9091 | 必须与 API listener 不同 |
max_concurrent_requests | 64 | 1..=16384 |
request_head_timeout_ms | 5000 | 1..=60000 |
[auth]
| 字段 | 默认值 | 说明 |
|---|---|---|
snapshot_url | 无 | 必填完整 URL;ScootGate 追加 since |
token | 无 | 必填控制面 Bearer token |
cache_path | ./data/scootgate-auth-snapshot.json | 摘要快照缓存 |
poll_interval_secs | 30 | 必须大于 0 |
配置文件包含 auth.token,生产应由 secret manager 渲染到 0600 文件。
[egress]
| 字段 | 默认值 | 说明 |
|---|---|---|
kind | 无 | http_connect 或 socks5 |
addr | 无 | 必填 host:port |
username | 无 | 必填专用服务账号 |
password | 无 | 必填密码 |
tls | false | 是否验证 TLS 并加密到代理 |
connect_timeout_ms | 10000 | 1..=60000 |
没有“direct”类型,也没有代理失败后的直连回落。
[api]
| 字段 | 默认值 | 约束 |
|---|---|---|
max_request_body_bytes | 1048576 | 1..=67108864 |
response_head_timeout_ms | 30000 | 1..=300000;选择、连接、TLS、写入和响应头共享 |
stream_idle_timeout_ms | 300000 | 1..=3600000 |
[routing]
| 字段 | 默认值 | 约束 |
|---|---|---|
max_pre_send_attempts | 2 | 1..=2;包含首次尝试 |
[routing.load_balancing]
| 字段 | 默认值 | 约束 |
|---|---|---|
algorithm | weighted_p2c | weighted_p2c 或 weighted_random |
ewma_alpha | 0.2 | (0, 1] |
initial_first_byte_ms | 1000 | 1..=600000 |
slow_start_secs | 30 | 0..=3600 |
[routing.circuit_breaker]
| 字段 | 默认值 | 约束 |
|---|---|---|
failure_threshold | 3 | 1..=1000 |
failure_window_secs | 30 | 1..=3600 |
cooldown_secs | 30 | 1..=3600 |
half_open_max_requests | 1 | 固定为 1 |
throttle_cooldown_secs | 10 | 0..=3600 |
[routing_source]
默认:
[routing_source]
mode = "static"
Snapshot 模式字段:
| 字段 | 默认值 | 说明 |
|---|---|---|
mode | static | 设为 snapshot |
snapshot_url | 空 | snapshot 模式必填 |
token_env | 空 | 保存控制面 token 的环境变量名 |
cache_path | ./data/scootgate-routing-snapshot.json | 最后有效快照 |
poll_interval_secs | 30 | 大于 0 |
max_stale_secs | 300 | 不小于 poll interval |
Static 模式禁止设置 snapshot_url / token_env;snapshot 模式禁止本地
[backends] / [models]。
[credentials.<id>]
每项必须且只能设置一个来源:
[credentials.provider-a]
env = "PROVIDER_A_API_KEY"
或:
[credentials.provider-a]
file = "/run/secrets/provider-a-api-key"
环境变量缺失、文件不可读或值为空会启动失败。secret 不会序列化到路由快照。
[backends.<id>]
| 字段 | 默认值 | 说明 |
|---|---|---|
protocol | 无 | openai_compatible 或 anthropic_messages |
upstream | 无 | 只有 scheme/host/port 的 origin |
credential_ref | 无 | 必须引用本地 credential |
max_inflight_per_node | 64 | 1..=65536 |
enabled | true | 是否参与选择 |
生产 upstream 必须为 HTTPS;HTTP 只允许 loopback。URL 不得包含 path、 query、fragment、userinfo 或内嵌凭据。
[models.<public-name>] 与 targets
[models.chat-fast]
protocol = "openai_compatible"
enabled = true
[[models.chat-fast.targets]]
backend = "provider-a"
model = "real-model-a"
weight = 100
supports_stream = true
supports_tools = true
supports_json_schema = true
| target 字段 | 默认值 | 说明 |
|---|---|---|
backend | 无 | 必须存在且协议匹配 |
model | 无 | 转发时写入的真实模型名 |
weight | 100 | 1..=1000000 |
supports_stream | true | 是否支持流式响应 |
supports_tools | true | 是否支持 tools |
supports_json_schema | true | 是否支持 JSON schema |
所有 ID 长度为 1..=128,字符限于 A-Z a-z 0-9 . _ : -。
[usage]
| 字段 | 默认值 | 约束 |
|---|---|---|
schema | 2 | 固定 2 |
delivery | mqtt_outbox | 固定值 |
authoritative | false | 固定 false |
outbox_path | ./data/usage-outbox | 非空持久目录 |
max_outbox_bytes | 1073741824 | 大于 0 |
flush_interval_ms | 500 | 1..=60000 |
on_full | drop_newest | 固定值 |
[mqtt]
| 字段 | 默认值 | 说明 |
|---|---|---|
broker | 无 | 必填,无 path |
username | 空 | 可选;设置 password 时必须非空 |
password | 空 | 可选 |
topic_prefix | scootgate | 不允许通配符或空白 |
client_id | scootgate-{instance_id} | 可覆盖 |
queue_capacity | 1024 | 大于 0 |
Broker scheme 支持 mqtt / tcp(默认端口 1883)与
mqtts / ssl / tls / tcps(默认端口 8883)。
[shutdown] 与 [log]
| 字段 | 默认值 | 说明 |
|---|---|---|
shutdown.grace_period_secs | 30 | 必须大于 0 |
log.level | info | tracing filter level |
日志为 JSON。若设置 RUST_LOG,它会覆盖由 log.level 构造的默认过滤器。
生产最佳实践
本章给出 ScootGate 的推荐生产基线。它不是替代平台安全规范的万能模板; 当可用性与授权、出口或计费副作用边界冲突时,优先保证后者正确。
1. 推荐拓扑
┌──────────────────────────┐
│ 外部控制面 │
│ auth / routing snapshots │
└────────────┬─────────────┘
│ HTTPS 拉取
客户端 ── TLS ──► ScootGate ── CONNECT ──► 专用出站代理 ── TLS ──► Provider
│ │
│ ├── MQTT:usage + snapshot notifications
│ └── 持久盘:snapshot cache + usage outbox
└── health listener(仅内网/本机)
推荐同时在两层强制出口边界:
- ScootGate 配置只提供一个受控 HTTP CONNECT 或 SOCKS5 出口;
- 主机或网络策略禁止 ScootGate 直接访问 Provider,只允许访问控制面、 MQTT、DNS 和专用出站代理。
第二层不是为了弥补应用逻辑,而是防止未来配置错误或回归绕开代理。
2. 配置与秘密
Provider 凭据
- 优先使用
[credentials.<id>].file指向 secret manager 挂载的只读文件; - 使用
env时,仅向 ScootGate 进程注入,不写进 shell profile、镜像或日志; - 每个 Provider/账户使用独立 credential ID,便于撤销和审计;
- 文件建议权限
0400,所属用户为运行 ScootGate 的无特权账户。
服务身份
当前 [auth].token、[egress].username/password 与
[mqtt].username/password 是配置字段。生产环境应由 secret manager
在启动前生成完整配置到 tmpfs 或受限目录,并设置 0600;不要把渲染后的
配置提交到 Git、烘焙进镜像或作为命令行参数传递。
routing_source.token_env 只写环境变量名,实际 token 由进程环境注入。
本地状态
将以下路径放在持久盘,并限制目录权限为 0700:
- 授权快照缓存;
- 路由快照缓存(snapshot 模式);
- 用量 outbox。
快照缓存与 outbox 不包含原始客户端 key 或 Provider key,但仍包含租户、 key ID、后端与流量元数据,必须按敏感运行数据保护。
3. 入口与健康面
- 生产 API listener 必须配置
[server.tls],证书至少支持 TLS 1.2; - health listener 与 API listener 使用不同端口;
- health listener 仅向本机探针、私网负载均衡器或 DNS controller 开放;
- 不要把
/healthz、/readyz暴露到公共互联网; - 私有 CA 通过
SCOOTGATE_EXTRA_CA_CERTS提供,不增加跳过校验的开关。
流量入口只依据 /readyz 接流:
/healthz表示进程存活;/readyz还要求授权快照可用、路由表可用且节点不在排空;- snapshot 路由控制面超过
max_stale_secs未成功刷新时,节点转为 unready,但继续使用最后一份有效路由表服务已有流量。
4. 强制代理出口
- 为每个节点或节点组分配专用代理服务身份,不复用个人账号;
- 跨不可信网络访问代理时启用
egress.tls = true; - 代理 TLS 和 Provider TLS 都必须使用可信根验证;
connect_timeout_ms应小于整体response_head_timeout_ms;- 上线前主动停止代理,确认请求返回 502 且 Provider 没收到连接。
ScootGate 不提供直连回落。代理不可用时降低成功率是预期的安全行为。
5. 模型与容量
公共模型名
公共模型名是客户端契约,应保持稳定;真实 Provider 模型名只放在 target 中。 不同协议必须使用不同模型池,不做 OpenAI 与 Anthropic 的隐式互转。
权重与能力
weight表示相对容量/成本意图,不等于严格流量百分比;- 默认
weighted_p2c会同时考虑权重、节点在途数、EWMA 首包延迟和慢启动; - 只有确认 target 支持时才开启
supports_stream、supports_tools、supports_json_schema; - 新后端先以较低权重加入,观察首包延迟、429 和 5xx 后再逐步提高。
在途上限
max_inflight_per_node 应取 Provider 配额、代理容量和节点内存三者中的保守值。
不要把它当作全局配额:每个 ScootGate 节点独立维护本地在途数。
6. 故障隔离与重试
- 保持
max_pre_send_attempts = 2:总共最多两次选择; - 只有连接代理或 Provider TLS 失败等
DefinitelyNotSent场景允许改选一次; - 请求字节可能发出后(
PossiblySent)绝不重试; - 401/403 表示网关持有的 Provider 凭据失效,整个 backend 会被停用;
- 429 原样返回,并让 backend 进入短暂冷却;
- 熔断半开探测固定单飞,避免恢复瞬间放大流量。
不要在外层反向代理、SDK 或 service mesh 中对 POST Provider 请求增加自动重试, 否则会绕过 ScootGate 的发送状态判断并产生重复计费风险。
7. static 与 snapshot 的选择
| 条件 | 推荐模式 |
|---|---|
| 单节点、路由很少变化、由配置发布系统管理 | static |
| 多节点、频繁调整后端/权重、需要集中控制 | snapshot |
两种模式严格互斥:
static:本地必须声明[backends]与[models];snapshot:本地禁止声明 backends/models,只保留 credentials;- 不存在字段级合并、局部覆盖或“本地兜底”。
snapshot 发布必须遵守:
- 版本严格递增;
- 每份快照完整包含全部 backends/models;
- 先在控制面完成引用与协议校验,再发布;
- MQTT 只发送
{"version": N}提示,真实配置仍由受认证 HTTPS 拉取; max_stale_secs >= poll_interval_secs,并与流量摘除窗口匹配。
坏快照会被整体拒绝,不会覆盖最后一份有效表。
8. 用量 outbox 与 MQTT
- outbox 必须位于持久盘,而不是容器临时层;
max_outbox_bytes必须小于卷可用容量,并预留快照、日志与文件系统空间;- 控制面按
event_id去重;QoS 1 和重启重放都可能产生重复投递; - 监控 health 响应中的
pending_events、pending_bytes、oldest_event_age_secs与dropped_events; - broker 故障时数据面继续服务;outbox 满后按
drop_newest丢新并告警; - token 只取 Provider 原生 usage;缺失保持
null,不得按响应字节估算。
该通道固定 authoritative = false,不能直接作为权威账单。
9. 发布与回滚
推荐按以下顺序滚动:
- 在预发布节点验证配置解析、代理失败、TLS、授权与模型目录;
- 新节点启动后等待
/readyz为 200; - 逐步加入流量并观察 429、5xx、熔断、首包延迟和 outbox;
- 再排空旧节点;
- snapshot 模式先发布新凭据到节点,再发布引用该凭据的新快照;
- 回滚时发布更高版本的“回滚内容”,不要重放旧版本号。
请求在开始时固定使用一份路由表;热更新不会改变进行中的请求。
10. 上线前故障演练
| 注入故障 | 必须观察到的结果 |
|---|---|
| 无授权快照冷启动 | /readyz 非 200,客户端 key 被拒绝 |
| 吊销客户端 key | 新授权快照生效后返回 401 |
| 停止出站代理 | 请求返回 502,Provider 无直连连接 |
| Provider TLS 不可信 | 请求失败,无跳过校验回落 |
| Provider 401/403 | 客户端收到 502,backend 被停用 |
| Provider 429 | 当前 429 原样返回,后续流量短暂避开该 backend |
| 非法路由快照 | 版本不切换,最后有效表继续服务 |
| 路由控制面超期不可达 | /readyz 转非 200,旧表继续服务 |
| MQTT 不可达 | 请求继续服务,outbox 积压增长 |
| outbox 达上限 | 新事件丢弃、计数增长,数据面继续服务 |
| SIGTERM | 节点先排空并在 grace period 内退出 |
11. 生产检查清单
- API listener 使用可信 TLS 证书;
- health listener 未暴露公网;
- 网络策略禁止 Provider 直连;
- 代理、控制面与 MQTT 使用专用服务身份;
- Provider 凭据只来自环境或只读文件;
- 配置文件与状态目录权限已收紧;
- 公共模型名、target 能力与协议已核验;
- 每 backend 在途上限与 Provider 配额匹配;
-
/readyz已接入入口摘除; - snapshot 版本、staleness 与回滚流程已演练;
- outbox 容量与四项健康指标已监控;
- 外层组件没有对 Provider POST 请求自动重试;
- 上表中的高风险失败路径已在预发布环境演练。
完整场景
以下场景使用仓库内经过解析测试的配置与快照文件,展示从拓扑、秘密准备、 启动到验收和故障演练的完整路径。
| 场景 | 适用环境 | 路由来源 | 重点 |
|---|---|---|---|
| Static 多 Provider 生产部署 | 单节点或配置发布频率低 | 本地 TOML | TLS、强制代理、加权多后端、Anthropic 独立协议面 |
| Snapshot 多节点动态路由 | 多节点、集中调整权重与后端 | 控制面快照 | 原子热更新、MQTT 通知、staleness、回滚 |
两个场景都遵守相同红线:
- 无授权快照不接流;
- Provider 流量只能经过配置的上游代理;
- 可能已发送的请求不自动重试;
- Provider 凭据不进入路由快照;
- 用量事件是非权威观测数据。
先阅读生产最佳实践,再选择最接近部署形态的场景。
Static 多 Provider 生产部署
本场景部署一个 ScootGate 节点,使用本地 TOML 管理路由:
chat-fast同时路由到一个 OpenAI 后端和一个 OpenAI-compatible 备用后端;claude-fast通过独立 Anthropic Messages 协议面路由;- 所有 Provider 流量经 TLS HTTP CONNECT 代理;
- API listener 原生终止 TLS;
- 用量事件经持久 outbox 投递 MQTT。
1. 拓扑
客户端
│ https://gate.example.com:8443
▼
ScootGate prod-a
├── HTTPS ──► auth control plane
├── MQTTS ──► MQTT broker
└── TLS CONNECT ──► egress proxy
├──► api.openai.com
├──► compatible.example.net
└──► api.anthropic.com
health listener 只绑定 127.0.0.1:9090,由同机探针或本机代理读取。
2. 前置条件
准备以下资源:
gate.example.com的证书与私钥;- 可访问三个 Provider 的 HTTP CONNECT 代理及专用账号;
- 授权快照 HTTPS 端点;
- MQTTS broker;
- 三份 Provider 凭据;
/var/lib/scootgate持久目录。
建议权限:
install -d -m 0700 -o scootgate -g scootgate /var/lib/scootgate
install -d -m 0700 -o scootgate -g scootgate /var/lib/scootgate/usage-outbox
install -d -m 0700 -o scootgate -g scootgate /run/secrets
install -d -m 0700 -o scootgate -g scootgate /etc/scootgate/tls
Provider key 应由 secret manager 直接挂载为:
/run/secrets/openai-primary-api-key
/run/secrets/compatible-backup-api-key
/run/secrets/anthropic-primary-api-key
文件权限设为 0400,不要用示例占位符启动生产节点。
3. 完整配置
instance_id = "scootgate-prod-a"
[server]
listen = "0.0.0.0:8443"
max_concurrent_requests = 2048
request_head_timeout_ms = 5000
[server.tls]
cert = "/etc/scootgate/tls/fullchain.pem"
key = "/etc/scootgate/tls/privkey.pem"
[health]
listen = "127.0.0.1:9090"
max_concurrent_requests = 64
request_head_timeout_ms = 2000
[auth]
snapshot_url = "https://control.example.com/v1/scootgate/auth-snapshot"
token = "REPLACE_WITH_AUTH_CONTROL_PLANE_TOKEN"
cache_path = "/var/lib/scootgate/auth-snapshot.json"
poll_interval_secs = 30
[egress]
kind = "http_connect"
addr = "egress-proxy.internal.example.com:8443"
username = "scootgate-prod-a"
password = "REPLACE_WITH_EGRESS_SERVICE_PASSWORD"
tls = true
connect_timeout_ms = 5000
[api]
max_request_body_bytes = 1048576
response_head_timeout_ms = 30000
stream_idle_timeout_ms = 300000
[routing]
max_pre_send_attempts = 2
[routing.load_balancing]
algorithm = "weighted_p2c"
ewma_alpha = 0.2
initial_first_byte_ms = 1000
slow_start_secs = 30
[routing.circuit_breaker]
failure_threshold = 3
failure_window_secs = 30
cooldown_secs = 30
half_open_max_requests = 1
throttle_cooldown_secs = 10
[credentials.openai-primary]
file = "/run/secrets/openai-primary-api-key"
[credentials.compatible-backup]
file = "/run/secrets/compatible-backup-api-key"
[credentials.anthropic-primary]
file = "/run/secrets/anthropic-primary-api-key"
[backends.openai-primary]
protocol = "openai_compatible"
upstream = "https://api.openai.com"
credential_ref = "openai-primary"
max_inflight_per_node = 64
[backends.compatible-backup]
protocol = "openai_compatible"
upstream = "https://compatible.example.net"
credential_ref = "compatible-backup"
max_inflight_per_node = 32
[backends.anthropic-primary]
protocol = "anthropic_messages"
upstream = "https://api.anthropic.com"
credential_ref = "anthropic-primary"
max_inflight_per_node = 64
[models.chat-fast]
protocol = "openai_compatible"
[[models.chat-fast.targets]]
backend = "openai-primary"
model = "gpt-4o-mini"
weight = 100
supports_stream = true
supports_tools = true
supports_json_schema = true
[[models.chat-fast.targets]]
backend = "compatible-backup"
model = "vendor-chat-fast"
weight = 40
supports_stream = true
supports_tools = false
supports_json_schema = false
[models.claude-fast]
protocol = "anthropic_messages"
[[models.claude-fast.targets]]
backend = "anthropic-primary"
model = "claude-3-5-haiku-latest"
weight = 100
supports_stream = true
supports_tools = true
supports_json_schema = true
[usage]
schema = 2
delivery = "mqtt_outbox"
authoritative = false
outbox_path = "/var/lib/scootgate/usage-outbox"
max_outbox_bytes = 1073741824
flush_interval_ms = 500
on_full = "drop_newest"
[mqtt]
broker = "mqtts://mqtt.internal.example.com:8883"
username = "scootgate-prod-a"
password = "REPLACE_WITH_MQTT_SERVICE_PASSWORD"
topic_prefix = "scootgate/prod"
client_id = "scootgate-prod-a"
queue_capacity = 8192
[shutdown]
grace_period_secs = 60
[log]
level = "info"
上线前至少替换:
- auth control-plane URL 与 token;
- egress proxy 地址、账号与密码;
- compatible 后端域名和真实模型名;
- MQTT 地址、账号与密码;
- TLS、Provider secret 与状态路径。
chat-fast 的第二个 target 不支持 tools/json schema。带这些能力的请求会只在
openai-primary 候选中选择,而不是把不兼容请求发给备用后端。
4. 授权快照
示例客户端 key 是 demo-client-key,快照只保存它的 SHA-256:
{
"version": 7,
"keys": [
{
"key_id": "demo-active",
"tenant_id": "tenant-demo",
"key_sha256": "299ccbc5cc6d53eb7860d96cb94e58213a550cd3aaa12d18f6ec2a3b305808a4",
"status": "active"
},
{
"key_id": "demo-revoked",
"tenant_id": "tenant-demo",
"key_sha256": "7077d33f1bf541153b1040c55f8df93735c8b118e9ab74d39e0f3a2f26e49b4f",
"status": "revoked"
}
]
}
控制面接口要求:
GET /v1/scootgate/auth-snapshot?since=0
Authorization: Bearer REPLACE_WITH_AUTH_CONTROL_PLANE_TOKEN
- 有更新:返回
200与完整 JSON; - 无更新:返回
304; - 后续版本必须严格大于当前版本。
示例中的 revoked-client-key 已标记 revoked,用它请求必须返回 401。
5. 启动
将渲染后的配置保存为 /etc/scootgate/scootgate.toml 并设置 0600:
scootgate --config /etc/scootgate/scootgate.toml
启动日志出现 listener 地址不代表已经接流;必须等待:
curl --fail --silent http://127.0.0.1:9090/readyz
冷启动没有授权缓存时,节点会保持 unready,直到控制面返回有效快照。
6. 验证
以下命令假设证书由 /etc/scootgate/tls/ca.pem 信任:
curl --fail \
--resolve gate.example.com:8443:127.0.0.1 \
--cacert /etc/scootgate/tls/ca.pem \
-H 'Authorization: Bearer demo-client-key' \
https://gate.example.com:8443/v1/models
应只看到公共模型 chat-fast 与 claude-fast,不能出现 backend ID、
Provider origin 或真实凭据引用。
OpenAI-compatible 请求:
curl --fail \
--resolve gate.example.com:8443:127.0.0.1 \
--cacert /etc/scootgate/tls/ca.pem \
-H 'Authorization: Bearer demo-client-key' \
-H 'Content-Type: application/json' \
-d '{"model":"chat-fast","messages":[{"role":"user","content":"ping"}]}' \
https://gate.example.com:8443/v1/chat/completions
Anthropic 请求:
curl --fail \
--resolve gate.example.com:8443:127.0.0.1 \
--cacert /etc/scootgate/tls/ca.pem \
-H 'x-api-key: demo-client-key' \
-H 'anthropic-version: 2023-06-01' \
-H 'Content-Type: application/json' \
-d '{"model":"claude-fast","max_tokens":32,"messages":[{"role":"user","content":"ping"}]}' \
https://gate.example.com:8443/v1/messages
验证代理日志中能看到三个 Provider 的 CONNECT,Provider 请求头中只有
ScootGate 持有的上游凭据,不能出现 demo-client-key。
7. 预期负载行为
- 无 tools/json schema 的
chat-fast请求在两个 target 间按权重、在途数和 首包 EWMA 选择; - tools/json schema 请求只选择
openai-primary; claude-fast永远不会进入 OpenAI-compatible 池;- 备用 target 连续传输失败后熔断,冷却结束只允许一个 half-open 探测;
- Provider 429 不触发当前请求重试。
权重是选择输入,不保证小样本严格按 100:40 分布。
8. 故障演练
| 操作 | 验收结果 |
|---|---|
使用 revoked-client-key | 401,Provider 与代理均无请求 |
| 停止 egress proxy | 502,三个 Provider 均无直连 |
| 让备用后端 TLS 证书不可信 | 仅在确认未发送字节时安全改选一次 |
| 让 Provider 返回 401 | 客户端 502,该 backend 后续不再收流 |
| 停止 MQTT | API 继续服务,usage_outbox.pending_events 增长 |
| 填满 outbox 配额 | dropped_events 增长,API 继续服务 |
| SIGTERM | /readyz 不再接流,进程在 60 秒 grace period 内退出 |
完成以上验证后,再把节点加入生产入口。
Snapshot 多节点动态路由
本场景在多个 ScootGate 节点上集中管理 backends/models。节点本地只保存 Provider 凭据引用的实际值,控制面通过版本化快照下发路由数据。
1. 拓扑与职责
┌────────────────────────────────┐
│ 控制面 │
│ auth snapshot / routing snapshot│
└──────────────┬─────────────────┘
│ HTTPS(Bearer)
┌───────────────────┴───────────────────┐
▼ ▼
ScootGate prod-a ScootGate prod-b
local credentials local credentials
│ │
└──────────────► egress proxy ◄─────────┘
│
▼
Providers
MQTT routing/notify:只提示版本,不携带路由内容
控制面负责:
- 生成完整、严格递增的路由快照;
- 只下发
credential_ref,不持有或下发 Provider key; - 为
since查询返回最新版本或 304; - 快照发布后可发送 MQTT 通知加速拉取。
节点负责:
- 从本地环境/文件解析 credential;
- 校验并原子替换完整路由表;
- 拒绝旧版本、未知引用与非法协议组合;
- 控制面超期不可达时转 unready,但继续服务最后有效表。
2. 节点完整配置
instance_id = "scootgate-prod-b"
[server]
listen = "0.0.0.0:8443"
max_concurrent_requests = 2048
request_head_timeout_ms = 5000
[server.tls]
cert = "/etc/scootgate/tls/fullchain.pem"
key = "/etc/scootgate/tls/privkey.pem"
[health]
listen = "127.0.0.1:9090"
max_concurrent_requests = 64
request_head_timeout_ms = 2000
[auth]
snapshot_url = "https://control.example.com/v1/scootgate/auth-snapshot"
token = "REPLACE_WITH_AUTH_CONTROL_PLANE_TOKEN"
cache_path = "/var/lib/scootgate/auth-snapshot.json"
poll_interval_secs = 30
[egress]
kind = "http_connect"
addr = "egress-proxy.internal.example.com:8443"
username = "scootgate-prod-b"
password = "REPLACE_WITH_EGRESS_SERVICE_PASSWORD"
tls = true
connect_timeout_ms = 5000
[api]
max_request_body_bytes = 1048576
response_head_timeout_ms = 30000
stream_idle_timeout_ms = 300000
[routing]
max_pre_send_attempts = 2
[routing.load_balancing]
algorithm = "weighted_p2c"
ewma_alpha = 0.2
initial_first_byte_ms = 1000
slow_start_secs = 30
[routing.circuit_breaker]
failure_threshold = 3
failure_window_secs = 30
cooldown_secs = 30
half_open_max_requests = 1
throttle_cooldown_secs = 10
[routing_source]
mode = "snapshot"
snapshot_url = "https://control.example.com/v1/scootgate/routing-snapshot"
token_env = "SCOOTGATE_ROUTING_TOKEN"
cache_path = "/var/lib/scootgate/routing-snapshot.json"
poll_interval_secs = 30
max_stale_secs = 300
[credentials.openai-primary]
file = "/run/secrets/openai-primary-api-key"
[credentials.compatible-backup]
file = "/run/secrets/compatible-backup-api-key"
[credentials.anthropic-primary]
file = "/run/secrets/anthropic-primary-api-key"
[usage]
schema = 2
delivery = "mqtt_outbox"
authoritative = false
outbox_path = "/var/lib/scootgate/usage-outbox"
max_outbox_bytes = 1073741824
flush_interval_ms = 500
on_full = "drop_newest"
[mqtt]
broker = "mqtts://mqtt.internal.example.com:8883"
username = "scootgate-prod-b"
password = "REPLACE_WITH_MQTT_SERVICE_PASSWORD"
topic_prefix = "scootgate/prod"
client_id = "scootgate-prod-b"
queue_capacity = 8192
[shutdown]
grace_period_secs = 60
[log]
level = "info"
注意此配置没有 [backends] 与 [models]。snapshot 模式声明任何本地
backend/model 都会启动失败,不存在本地与远端合并。
在进程管理器中注入路由控制面 token:
export SCOOTGATE_ROUTING_TOKEN='REPLACE_WITH_ROUTING_CONTROL_PLANE_TOKEN'
scootgate --config /etc/scootgate/scootgate.toml
生产环境不要在交互式 shell 中导出真实 token;应由 secret manager 注入进程环境。
3. 初始路由快照
版本 41 提供两个 OpenAI-compatible target 和一个 Anthropic target:
{
"schema": 1,
"version": 41,
"backends": {
"openai-primary": {
"protocol": "openai_compatible",
"upstream": "https://api.openai.com",
"credential_ref": "openai-primary",
"max_inflight_per_node": 64,
"enabled": true
},
"compatible-backup": {
"protocol": "openai_compatible",
"upstream": "https://compatible.example.net",
"credential_ref": "compatible-backup",
"max_inflight_per_node": 32,
"enabled": true
},
"anthropic-primary": {
"protocol": "anthropic_messages",
"upstream": "https://api.anthropic.com",
"credential_ref": "anthropic-primary",
"max_inflight_per_node": 64,
"enabled": true
}
},
"models": {
"chat-fast": {
"protocol": "openai_compatible",
"enabled": true,
"targets": [
{
"backend": "openai-primary",
"model": "gpt-4o-mini",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
},
{
"backend": "compatible-backup",
"model": "vendor-chat-fast",
"weight": 40,
"supports_stream": true,
"supports_tools": false,
"supports_json_schema": false
}
]
},
"claude-fast": {
"protocol": "anthropic_messages",
"enabled": true,
"targets": [
{
"backend": "anthropic-primary",
"model": "claude-3-5-haiku-latest",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
}
]
}
}
}
控制面接口:
GET /v1/scootgate/routing-snapshot?since=0
Authorization: Bearer REPLACE_WITH_ROUTING_CONTROL_PLANE_TOKEN
响应:
HTTP/1.1 200 OK
Content-Type: application/json
{完整版本 41 快照}
若节点请求 since=41 且没有更新,返回:
HTTP/1.1 304 Not Modified
Content-Length: 0
成功的 200 或 304 都刷新 control-plane freshness。网络错误、401、非法 JSON 或编译失败都不会刷新版本,也不会覆盖旧表。
4. 冷启动顺序
- 节点加载授权缓存;没有则保持 unready;
- 节点加载路由缓存;没有则 API 返回 503
not_ready; - auth 与 routing poller 分别拉取控制面;
- 两份有效快照都加载后
/readyz返回 200; - 流量入口才把节点加入服务集合。
示例健康响应:
{
"service": "scootgate",
"instance_id": "scootgate-prod-b",
"version": "0.1.0",
"status": "ready",
"ready": true,
"auth_snapshot_version": 7,
"routing_snapshot_version": 41,
"usage_outbox": {
"pending_events": 0,
"pending_bytes": 0,
"dropped_events": 0,
"oldest_event_age_secs": null
}
}
5. 热更新权重
版本 42 把备用 OpenAI-compatible target 从权重 40 提高到 100:
{
"schema": 1,
"version": 42,
"backends": {
"openai-primary": {
"protocol": "openai_compatible",
"upstream": "https://api.openai.com",
"credential_ref": "openai-primary",
"max_inflight_per_node": 64,
"enabled": true
},
"compatible-backup": {
"protocol": "openai_compatible",
"upstream": "https://compatible.example.net",
"credential_ref": "compatible-backup",
"max_inflight_per_node": 32,
"enabled": true
},
"anthropic-primary": {
"protocol": "anthropic_messages",
"upstream": "https://api.anthropic.com",
"credential_ref": "anthropic-primary",
"max_inflight_per_node": 64,
"enabled": true
}
},
"models": {
"chat-fast": {
"protocol": "openai_compatible",
"enabled": true,
"targets": [
{
"backend": "openai-primary",
"model": "gpt-4o-mini",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
},
{
"backend": "compatible-backup",
"model": "vendor-chat-fast",
"weight": 100,
"supports_stream": true,
"supports_tools": false,
"supports_json_schema": false
}
]
},
"claude-fast": {
"protocol": "anthropic_messages",
"enabled": true,
"targets": [
{
"backend": "anthropic-primary",
"model": "claude-3-5-haiku-latest",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
}
]
}
}
}
推荐发布顺序:
-
控制面先把版本 42 设为当前完整快照;
-
再向
scootgate/prod/routing/notify发布 QoS 1 消息:{"version": 42} -
节点收到通知后等待 500ms 合并突发,再用 bearer token 拉取完整快照;
-
/healthz中routing_snapshot_version变为 42; -
观察两个 target 的选择量、EWMA、429 与熔断状态。
通知只是一种加速。消息丢失时,30 秒轮询仍会获取新版本;通知不能直接改状态。
6. 状态继承与请求一致性
版本 41 → 42 只修改 target 权重,三个 backend 的 id、协议、origin、 credential_ref 与容量不变,因此节点保留其:
- 在途计数;
- EWMA 首包延迟;
- 熔断与 half-open 状态;
- 429 冷却;
- 因 401/403 产生的 disabled 状态。
若上述 backend 身份字段任一变化,则创建新 runtime 状态。
每个请求在开始时固定一个 Arc<RoutingTable>。更新后:
- 新请求使用版本 42;
- 进行中的请求继续使用版本 41;
- 不会在一次流式响应中跨版本换 backend。
7. 回滚
版本号必须严格递增,不能重新发布 41。回滚版本 42 的内容时:
- 复制版本 41 的 backends/models 内容;
- 把版本号设为 43;
- 先发布完整版本 43;
- 再发送
{"version":43}通知; - 确认所有节点健康面已切换。
这保证乱序、重复或延迟消息不会让节点状态倒退。
8. 故障与恢复
| 故障 | 节点行为 | 恢复 |
|---|---|---|
| 快照引用未知 credential | 整份拒绝,继续版本 41 | 发布更高版本的有效完整快照 |
| schema 不是 1 | 整份拒绝 | 修正 schema 并提高版本 |
| 收到版本 40/41 通知 | 忽略,不拉取旧状态 | 无需操作 |
| MQTT 断线 | 轮询继续,数据面不受阻 | 重连后恢复通知 |
| 控制面短暂不可达 | 旧表继续服务,仍 ready | 成功 200/304 后刷新 freshness |
| 超过 300 秒不可达 | 旧表继续服务,/readyz 非 200 | 拉取成功后自动恢复 ready |
| 新版本编译失败后发布 43 | 版本 41 保留直到 43 成功 | 原子切到 43 |
9. 多节点验收
对每个节点分别确认:
auth_snapshot_version与routing_snapshot_version达到目标;/readyz为 200 后才加入入口;- 版本更新期间持续请求无中断;
- 坏版本从未出现在健康面;
- 控制面超期时节点被入口摘除;
- MQTT/outbox 不包含 Provider key、客户端 key 或请求正文;
- 任一节点退出只损失容量,不影响其他节点路由。
客户端 HTTP API
本规范描述客户端与 ScootGate API listener 的当前 HTTP 契约。
通用要求
- 传输:HTTP/1.1;生产环境使用 TLS 1.2+;
- 每个 API listener 请求都必须鉴权,包括
/v1/models; - POST 请求必须带唯一、有效的
Content-Length; - JSON 请求体大小受
api.max_request_body_bytes限制; - 每个响应使用
Connection: close; - 未声明的路径返回 404,不存在管理 API。
鉴权
支持两种请求头:
Authorization: Bearer <scootgate-client-key>
x-api-key: <scootgate-client-key>
两者同时出现时,有效 Bearer 值优先。key 缺失、未知或已吊销时:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: application/json
{"status":"unauthorized"}
客户端 key 只用于 ScootGate 鉴权,不会转发给 Provider。
支持的端点
| 方法 | 路径 | 协议 |
|---|---|---|
POST | /v1/chat/completions | OpenAI-compatible |
POST | /v1/messages | Anthropic Messages |
GET | /v1/models | OpenAI-compatible 模型目录 |
路径存在但方法错误时返回:
{"status":"method_not_allowed"}
状态码为 405。
GET /v1/models
只列出已启用的 OpenAI-compatible 公共模型。Anthropic 模型、backend ID、 真实模型名和 Provider origin 不会出现。
响应示例:
{
"object": "list",
"data": [
{
"id": "chat-fast",
"object": "model",
"owned_by": "scootgate"
}
]
}
POST /v1/chat/completions
请求体必须是 JSON object,并包含字符串 model:
POST /v1/chat/completions HTTP/1.1
Authorization: Bearer demo-client-key
Content-Type: application/json
Content-Length: 67
{"model":"chat-fast","messages":[{"role":"user","content":"ping"}]}
ScootGate 选择 openai_compatible target,把公共 model 改写为真实模型名,
保留其他 JSON 字段,然后注入 backend credential。
当 stream: true 时要求 target 支持 stream,并透明转发 Provider SSE。
POST /v1/messages
请求体同样必须包含字符串 model:
POST /v1/messages HTTP/1.1
x-api-key: demo-client-key
anthropic-version: 2023-06-01
Content-Type: application/json
Content-Length: 85
{"model":"claude-fast","max_tokens":32,"messages":[{"role":"user","content":"ping"}]}
只选择 anthropic_messages target。anthropic-version 与 anthropic-beta
会转发给 Provider;客户端 x-api-key 会被 backend credential 替换。
能力过滤
ScootGate 从请求体推导所需能力:
| 请求字段 | 所需能力 |
|---|---|
"stream": true | supports_stream |
非空 tools(或非 array 值) | supports_tools |
response_format.type == "json_schema" | supports_json_schema |
模型存在但没有 target 同时满足能力时返回 400,不会出网。
请求头转发
客户端请求头采用 allowlist。只可能转发:
Content-TypeAcceptanthropic-versionanthropic-beta
ScootGate 重建 request line、Host、Provider 鉴权、Content-Length 与
Connection。客户端 Authorization、x-api-key、Host、代理头和其他
自定义头不会转发。
Provider 响应
除以下特殊处理外,ScootGate 转发 Provider 状态码、响应头和 body:
- 移除
Connection、Keep-Alive、Proxy-Connection,Proxy-Authenticate与TE; - 添加
Connection: close; - Provider 401/403 不原样返回:backend 被停用,客户端收到 502;
- Provider 429 原样返回,并让 backend 进入短暂冷却;
- SSE 按字节增量转发;客户端断开会关闭 Provider tunnel;
- Provider body 超过
stream_idle_timeout_ms无数据时结束 relay。
网关错误
通用错误 body
| 状态 | body | 场景 |
|---|---|---|
| 400 | {"status":"bad_request"} | HTTP 头或 framing 不合法 |
| 401 | {"status":"unauthorized"} | key 缺失/未知/吊销 |
| 404 | {"status":"not_found"} | 未知路径 |
| 405 | {"status":"method_not_allowed"} | 方法错误 |
| 411 | {"status":"length_required"} | POST 缺少 Content-Length |
| 413 | {"status":"payload_too_large"} | 请求体超过上限 |
| 502 | {"status":"bad_gateway"} | 出站、TLS、写入、响应头或 Provider 鉴权失败 |
| 503 | {"status":"not_ready"} | snapshot 路由尚未加载 |
| 504 | {"status":"gateway_timeout"} | 响应头预算耗尽 |
OpenAI-shaped 模型错误
无效 JSON、缺少 model、未知模型、能力不支持或无 backend 时使用:
{
"error": {
"message": "model `missing` does not exist",
"type": "not_found_error",
"param": "model",
"code": null
}
}
未知模型为 404;无效请求/能力不足为 400;无可用 backend 为 503。
Anthropic-shaped 模型错误
{
"type": "error",
"error": {
"type": "not_found_error",
"message": "model `missing` does not exist"
}
}
重试语义
ScootGate 不自动重试可能已到达 Provider 的请求。只有在能证明尚未发送
Provider 请求字节时,才可能在 max_pre_send_attempts 范围内改选其他
backend。客户端和外层代理也不应对这些 POST 请求进行无条件自动重试。
控制面快照协议
ScootGate 通过两个独立 HTTPS pull 协议消费控制面状态:
- 授权快照:客户端 API key 摘要;
- 路由快照:backends 与公共 models(仅 snapshot 模式)。
MQTT 只提示“可能有新版本”,不能携带或直接应用快照。
通用拉取契约
ScootGate 在配置 URL 后追加当前版本:
GET <snapshot_url>?since=<current-version>
Authorization: Bearer <service-token>
如果配置 URL 已有 query,则使用 &since=。
控制面响应:
| 状态 | 含义 |
|---|---|
200 OK | body 为完整 JSON 快照 |
304 Not Modified | 没有更新,body 为空 |
| 其他 | 同步失败,保留当前状态 |
两类 body 上限均为 8 MiB。网络、HTTP、JSON、schema 或语义校验失败时, 旧快照保持不变。失败轮询指数退避,最长 300 秒;MQTT 通知会在 500ms debounce 后提前触发一次相同的受认证拉取。
控制面应使用 HTTPS。Bearer token 只授予对应节点读取快照的最小权限。
授权快照
配置:
[auth]
snapshot_url = "https://control.example.com/v1/auth-snapshot"
token = "AUTH_CONTROL_TOKEN"
cache_path = "/var/lib/scootgate/auth-snapshot.json"
poll_interval_secs = 30
JSON schema
{
"version": 7,
"keys": [
{
"key_id": "client-123",
"tenant_id": "tenant-a",
"key_sha256": "299ccbc5cc6d53eb7860d96cb94e58213a550cd3aaa12d18f6ec2a3b305808a4",
"status": "active"
}
]
}
完整示例:auth-snapshot.json
字段要求
| 字段 | 类型 | 要求 |
|---|---|---|
version | u64 | 大于 0 且严格大于当前版本 |
keys | array | 完整 key 集合 |
key_id | string | 非空 |
tenant_id | string | 非空 |
key_sha256 | string | 原始客户端 key 的 64 位 SHA-256 hex |
status | string | active 或 revoked |
同一快照不得出现重复 digest,未知字段会使整份快照失败。hex 大小写均可, 节点内部规范化为小写。只有 active key 进入查找表;revoked 与未知 key 在数据面都表现为 401。
控制面计算 digest:
lowercase_hex(SHA-256(UTF-8 bytes of the exact client API key))
不要对 key 做 trim、Unicode 规范化或其他预处理。
路由快照
仅在以下模式启用:
[routing_source]
mode = "snapshot"
snapshot_url = "https://control.example.com/v1/routing-snapshot"
token_env = "SCOOTGATE_ROUTING_TOKEN"
cache_path = "/var/lib/scootgate/routing-snapshot.json"
poll_interval_secs = 30
max_stale_secs = 300
Bearer token 从 token_env 指向的环境变量读取。
JSON schema
{
"schema": 1,
"version": 41,
"backends": {
"provider-a": {
"protocol": "openai_compatible",
"upstream": "https://api.example.com",
"credential_ref": "provider-a",
"max_inflight_per_node": 64,
"enabled": true
}
},
"models": {
"chat-fast": {
"protocol": "openai_compatible",
"enabled": true,
"targets": [
{
"backend": "provider-a",
"model": "real-model",
"weight": 100,
"supports_stream": true,
"supports_tools": true,
"supports_json_schema": true
}
]
}
}
}
完整示例:
顶层要求
| 字段 | 类型 | 要求 |
|---|---|---|
schema | u32 | 固定为 1 |
version | u64 | 严格大于已应用版本 |
backends | object | 完整 backend map,至少一项 |
models | object | 完整公共 model map,至少一项 |
内部字段与配置参考一致。额外要求:
credential_ref必须存在于节点本地[credentials];- 快照绝不能包含 Provider credential 值;
- model target 引用的 backend 必须存在且协议一致;
- upstream 必须是合法 origin,生产使用 HTTPS;
- model target 池不能为空,权重与容量必须在范围内;
- 任意未知字段或非法引用都会整体拒绝。
原子应用与状态继承
快照完整编译成功后一次性替换。请求在开始时固定一份表,热更新不影响在途请求。
backend 的 id、protocol、origin、credential 内容和容量均不变时,节点继承:
- EWMA 首包延迟;
- 在途计数;
- 熔断/half-open;
- 429 冷却;
- 401/403 停用状态。
任一身份字段变化会创建新的 backend runtime。
Freshness
成功的 200 应用或 304 会刷新 routing control-plane freshness。
超过 max_stale_secs 没有成功同步时:
/readyz返回 503;- 已有最后有效表继续服务;
- 流量入口应摘除该节点;
- 后续成功同步会自动恢复 ready。
发布与回滚
控制面必须先使完整快照可被 HTTPS 拉取,再发送 MQTT 版本通知。
版本号不能回退。若要恢复版本 41 的内容,应发布相同内容但使用新版本 43, 而不是重新发送 41。这样重复、乱序和延迟消息不会倒退节点状态。
缓存
节点将最后有效快照原子写入本地缓存(Unix 文件权限 0600),重启时先加载缓存:
- 授权缓存只含 digest 与身份;
- 路由缓存只含 credential reference;
- 缓存损坏或过大时忽略并等待控制面;
- 缓存不是控制面事实源,后续仍持续轮询。
MQTT 与用量事件协议
MQTT 承载三类消息:
| 方向 | Topic | QoS | 功能 |
|---|---|---|---|
| ScootGate → broker | {prefix}/usage | 1 | 非权威用量事件 |
| broker → ScootGate | {prefix}/auth/notify | 1 | 授权快照更新提示 |
| broker → ScootGate | {prefix}/routing/notify | 1 | 路由快照更新提示 |
默认 prefix 为 scootgate。ScootGate 使用 clean session,每次连接后重新订阅
两个 notify topic;keep-alive 为 30 秒,连接错误后退避 1 秒重连。
Broker URL
支持:
mqtt://host[:port]、tcp://host[:port](默认 1883);mqtts://host[:port]、ssl://、tls://、tcps://(默认 8883)。
URL 不允许 path。生产使用 TLS scheme 和专用服务身份。
快照通知
两个 notify topic 使用同一 payload:
{"version": 42}
约束:
- JSON body 最大 4 KiB;
version为 u64;- 只有严格大于本地当前版本才触发拉取;
- malformed、重复、旧版本和乱序通知被忽略;
- 通知只触发受认证 HTTPS 全量拉取,不直接修改状态;
- 500ms debounce 会合并突发通知;
- 通知丢失不影响正确性,定时轮询仍会发现版本。
控制面应使用 QoS 1 发布,不需要 retain;即使发生重复投递也必须保持幂等。
用量事件投递
请求完成后,事件先写入本地磁盘 outbox,再向 {prefix}/usage 发布:
- MQTT QoS 1;
retain = false;- 收到匹配 PUBACK 后删除 outbox 文件;
- broker 断线或进程重启后重放;
event_id在重放时保持不变;- 消费方必须按
event_id去重。
outbox 满或不可写时,ScootGate 按 drop_newest 丢弃新事件并增加健康计数,
但 Provider API 数据面继续服务。
Unix 平台上,outbox 目录权限为 0700,事件文件权限为 0600;写入采用临时文件、
fsync 和原子 rename。运维备份或诊断不得把这些文件上传到第三方系统。
Usage event schema 2
示例:
{
"schema": 2,
"event_id": "gate-a-1784700000000000000-17",
"instance_id": "gate-a",
"tenant_id": "tenant-1",
"key_id": "key-1",
"provider": "openai_compatible",
"path": "/v1/chat/completions",
"status": 200,
"outcome": "relayed",
"public_model": "chat-fast",
"upstream_model": "gpt-4o-mini",
"backend_id": "openai-primary",
"selection_attempts": 1,
"attempted_backend_ids": ["openai-primary"],
"failure_stage": null,
"send_state": "response_received",
"charge_state": "provider_reported",
"routing_version": 41,
"prompt_tokens": 12,
"completion_tokens": 7,
"request_body_bytes": 138,
"response_body_bytes": 512,
"first_byte_ms": 220,
"duration_ms": 640,
"ts_unix_ms": 1784700000000
}
字段
| 字段 | 类型 | 说明 |
|---|---|---|
schema | u32 | 固定 2 |
event_id | string | 稳定幂等键 |
instance_id | string | ScootGate 节点 |
tenant_id / key_id | string | 授权快照中的身份 |
provider | string | openai_compatible 或 anthropic_messages |
path | string | 客户端 Provider API 路径 |
status | u16 | 返回客户端的状态码 |
outcome | string | 请求结果分类 |
public_model | string/null | 客户端公共模型 |
upstream_model | string/null | 改写后的 Provider 模型 |
backend_id | string/null | 最后选择/尝试的 backend |
selection_attempts | u32 | 总选择次数 |
attempted_backend_ids | array | 按顺序记录尝试 |
failure_stage | string/null | 失败阶段 |
send_state | string | Provider 发送状态 |
charge_state | string | token/计费不确定性 |
routing_version | u64 | snapshot 版本;static 模式为表 fingerprint |
prompt_tokens / completion_tokens | u64/null | Provider 原生 usage |
request_body_bytes / response_body_bytes | u64 | relay 字节计数 |
first_byte_ms | u64/null | Provider 首包延迟 |
duration_ms | u64 | 请求总时长 |
ts_unix_ms | u64 | 事件创建 Unix 毫秒时间 |
send_state
| 值 | 含义 |
|---|---|
not_sent | 选择前拒绝,未尝试 backend |
definitely_not_sent | 尝试失败且能证明 Provider 请求字节未发出 |
possibly_sent | 请求可能到达 Provider,不能安全重试 |
response_received | 已收到 Provider 响应头 |
charge_state
| 值 | 含义 |
|---|---|
not_sent | Provider 未收到请求 |
unknown | 可能发送/已响应,但 Provider 未报告 token usage |
provider_reported | 至少一个 token 字段来自 Provider 原生 usage |
缺失 token 必须是 JSON null,不能根据字节数估算。
常见 failure_stage
selectionreselect_exhaustedegress_connectprovider_tlsprovider_writeresponse_headprovider_authnull(正常 relay 或 Provider 429 等已收到响应场景)
常见 outcome
relayedthrottledbad_requestunknown_modelunsupported_model_capabilityno_backend_availablebad_gatewaygateway_timeoutupstream_auth_rejected
数据最小化
事件和 outbox 明确不包含:
- 客户端原始 API key;
- Provider credential;
- Authorization / x-api-key header;
- prompt、messages、tools 或响应正文;
- Provider 完整 URL query;
- TLS key/certificate 内容。
该协议固定为非权威观测通道。需要权威计费时必须使用单独设计,不能仅依赖 MQTT QoS 1、磁盘 outbox 或此 schema。
健康与就绪协议
Health listener 与客户端 API listener 分离,不要求 API key。生产环境应只允许 本机探针、私网负载均衡器或 DNS controller 访问。
方法与路径
| 方法 | 路径 | 响应 |
|---|---|---|
GET / HEAD | /healthz | 存活时 200 |
GET / HEAD | /readyz | 就绪时 200,否则 503 |
其他方法返回 405,未知路径返回 404。Query 不影响路径匹配。HEAD 返回与 GET
相同状态和 Content-Length,但没有 body。
响应 schema
{
"service": "scootgate",
"instance_id": "gate-a",
"version": "0.1.0",
"status": "ready",
"ready": true,
"auth_snapshot_version": 7,
"routing_snapshot_version": 41,
"usage_outbox": {
"pending_events": 0,
"pending_bytes": 0,
"dropped_events": 0,
"oldest_event_age_secs": null
}
}
| 字段 | 类型 | 说明 |
|---|---|---|
service | string | 固定 scootgate |
instance_id | string | 节点配置 ID |
version | string | 二进制版本 |
status | string | alive、starting、ready 或 draining |
ready | bool | 当前能否接收新流量 |
auth_snapshot_version | u64 | 当前授权版本;0 表示未加载 |
routing_snapshot_version | u64 | snapshot 路由版本;static 模式为 0 |
usage_outbox.pending_events | u64 | 未确认事件数 |
usage_outbox.pending_bytes | u64 | 未确认 payload 字节 |
usage_outbox.dropped_events | u64 | 进程生命周期累计丢弃数 |
usage_outbox.oldest_event_age_secs | u64/null | 最老积压事件年龄 |
/healthz
只要进程和 health listener 能响应就返回 200。status 固定为 alive,
但 ready 仍反映真实就绪状态,因此调用方可以在一次请求中同时观察存活与就绪。
不要用 /healthz 决定是否接入 Provider 流量。
/readyz
只有以下条件全部成立才返回 200:
auth_snapshot_version > 0;- 路由表可用;
- snapshot 路由最近一次成功同步未超过
max_stale_secs; - 节点未进入 draining。
否则返回 503 和相同 JSON schema。
状态解释
status | /readyz | 含义 |
|---|---|---|
starting | 503 | 缺授权/路由,或 snapshot 路由过期 |
ready | 200 | 可接新流量 |
draining | 503 | 收到停机信号,正在等待在途请求 |
Static 路由不会因控制面 staleness 变为 unready;授权快照目前只要求已加载 有效版本,不设置 staleness readiness 窗口。
探针建议
- liveness:
GET /healthz; - readiness:
GET /readyz; - 周期应明显短于路由
max_stale_secs; - 连续 readiness 失败后从入口摘除,但不要立刻杀死进程;
- draining 立即停止分配新请求;
- 对
pending_bytes、oldest_event_age_secs与dropped_events单独告警。
示例:
curl --fail --silent http://127.0.0.1:9091/healthz
curl --fail --silent http://127.0.0.1:9091/readyz