Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

ScootGate

ScootGate 是独立部署、默认拒绝(fail-closed)的 AI Provider API 网关。 它消费控制面下发的授权与路由快照,强制所有 Provider 流量经上游代理出站, 并提供 OpenAI 与 Anthropic 兼容入口。

项目优先保证授权、出口与计费副作用边界正确:

  • 鉴权、代理出口或 Provider TLS 校验失败时不回落直连;
  • 请求可能到达 Provider 后不自动重试;
  • 客户端凭据、Provider 凭据和请求正文不进入日志或用量事件;
  • 路由快照校验后原子生效,坏版本保留最后一份有效配置。

本站内容

本站只发布面向使用者和集成方的稳定文档:

  • 功能手册:安装启动、配置、模型路由、故障隔离、用量 outbox 和运维;
  • 完整场景:可复制的 static 与 snapshot 生产部署;
  • 协议规范:客户端 HTTP API、控制面快照、MQTT 用量事件和健康接口。

各页面以可观察行为和稳定集成契约为边界,不把实现过程作为软件使用契约。

从哪里开始

  1. 首次使用:阅读快速开始
  2. 准备生产配置:阅读配置参考生产最佳实践
  3. 对接控制面或 MQTT:直接进入协议规范
  4. 复制完整部署:选择 StaticSnapshot 场景。

本地预览

安装 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. 准备依赖

至少需要:

  1. 授权快照端点:按控制面快照协议 返回客户端 API key 的 SHA-256 摘要;
  2. 出站代理:支持 HTTP CONNECT 或 SOCKS5,并为 ScootGate 分配专用身份;
  3. Provider 凭据:由环境变量或只读文件提供;
  4. MQTT broker:接收用量事件并发送快照更新通知;
  5. 持久目录:保存授权/路由缓存和用量 outbox;
  6. 生产 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/completionsOpenAI-compatible 聊天与 SSE
POST/v1/messagesAnthropic 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 出口代理
apiAPI 大小与超时边界
routing负载与熔断参数
routing_sourcestaticsnapshot
credentials视模式Provider 凭据引用
backends / modelsstatic 必填本地路由数据
usage非权威磁盘 outbox
mqtt用量和快照通知
shutdown有界停机
logJSON 日志级别

[server][server.tls]

字段默认值约束
listen必填 IP:port
max_concurrent_requests10241..=16384
request_head_timeout_ms50001..=60000,同时约束 TLS 握手
tls.certPEM 证书链路径
tls.keyPEM 私钥路径

生产 API listener 必须配置 TLS。未配置时为明文开发模式。

[health]

字段默认值约束
listen127.0.0.1:9091必须与 API listener 不同
max_concurrent_requests641..=16384
request_head_timeout_ms50001..=60000

[auth]

字段默认值说明
snapshot_url必填完整 URL;ScootGate 追加 since
token必填控制面 Bearer token
cache_path./data/scootgate-auth-snapshot.json摘要快照缓存
poll_interval_secs30必须大于 0

配置文件包含 auth.token,生产应由 secret manager 渲染到 0600 文件。

[egress]

字段默认值说明
kindhttp_connectsocks5
addr必填 host:port
username必填专用服务账号
password必填密码
tlsfalse是否验证 TLS 并加密到代理
connect_timeout_ms100001..=60000

没有“direct”类型,也没有代理失败后的直连回落。

[api]

字段默认值约束
max_request_body_bytes10485761..=67108864
response_head_timeout_ms300001..=300000;选择、连接、TLS、写入和响应头共享
stream_idle_timeout_ms3000001..=3600000

[routing]

字段默认值约束
max_pre_send_attempts21..=2;包含首次尝试

[routing.load_balancing]

字段默认值约束
algorithmweighted_p2cweighted_p2cweighted_random
ewma_alpha0.2(0, 1]
initial_first_byte_ms10001..=600000
slow_start_secs300..=3600

[routing.circuit_breaker]

字段默认值约束
failure_threshold31..=1000
failure_window_secs301..=3600
cooldown_secs301..=3600
half_open_max_requests1固定为 1
throttle_cooldown_secs100..=3600

[routing_source]

默认:

[routing_source]
mode = "static"

Snapshot 模式字段:

字段默认值说明
modestatic设为 snapshot
snapshot_urlsnapshot 模式必填
token_env保存控制面 token 的环境变量名
cache_path./data/scootgate-routing-snapshot.json最后有效快照
poll_interval_secs30大于 0
max_stale_secs300不小于 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>]

字段默认值说明
protocolopenai_compatibleanthropic_messages
upstream只有 scheme/host/port 的 origin
credential_ref必须引用本地 credential
max_inflight_per_node641..=65536
enabledtrue是否参与选择

生产 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转发时写入的真实模型名
weight1001..=1000000
supports_streamtrue是否支持流式响应
supports_toolstrue是否支持 tools
supports_json_schematrue是否支持 JSON schema

所有 ID 长度为 1..=128,字符限于 A-Z a-z 0-9 . _ : -

[usage]

字段默认值约束
schema2固定 2
deliverymqtt_outbox固定值
authoritativefalse固定 false
outbox_path./data/usage-outbox非空持久目录
max_outbox_bytes1073741824大于 0
flush_interval_ms5001..=60000
on_fulldrop_newest固定值

[mqtt]

字段默认值说明
broker必填,无 path
username可选;设置 password 时必须非空
password可选
topic_prefixscootgate不允许通配符或空白
client_idscootgate-{instance_id}可覆盖
queue_capacity1024大于 0

Broker scheme 支持 mqtt / tcp(默认端口 1883)与 mqtts / ssl / tls / tcps(默认端口 8883)。

[shutdown][log]

字段默认值说明
shutdown.grace_period_secs30必须大于 0
log.levelinfotracing 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(仅内网/本机)

推荐同时在两层强制出口边界:

  1. ScootGate 配置只提供一个受控 HTTP CONNECT 或 SOCKS5 出口;
  2. 主机或网络策略禁止 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_streamsupports_toolssupports_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 发布必须遵守:

  1. 版本严格递增;
  2. 每份快照完整包含全部 backends/models;
  3. 先在控制面完成引用与协议校验,再发布;
  4. MQTT 只发送 {"version": N} 提示,真实配置仍由受认证 HTTPS 拉取;
  5. max_stale_secs >= poll_interval_secs,并与流量摘除窗口匹配。

坏快照会被整体拒绝,不会覆盖最后一份有效表。

8. 用量 outbox 与 MQTT

  • outbox 必须位于持久盘,而不是容器临时层;
  • max_outbox_bytes 必须小于卷可用容量,并预留快照、日志与文件系统空间;
  • 控制面按 event_id 去重;QoS 1 和重启重放都可能产生重复投递;
  • 监控 health 响应中的 pending_eventspending_bytesoldest_event_age_secsdropped_events
  • broker 故障时数据面继续服务;outbox 满后按 drop_newest 丢新并告警;
  • token 只取 Provider 原生 usage;缺失保持 null,不得按响应字节估算。

该通道固定 authoritative = false,不能直接作为权威账单。

9. 发布与回滚

推荐按以下顺序滚动:

  1. 在预发布节点验证配置解析、代理失败、TLS、授权与模型目录;
  2. 新节点启动后等待 /readyz 为 200;
  3. 逐步加入流量并观察 429、5xx、熔断、首包延迟和 outbox;
  4. 再排空旧节点;
  5. snapshot 模式先发布新凭据到节点,再发布引用该凭据的新快照;
  6. 回滚时发布更高版本的“回滚内容”,不要重放旧版本号。

请求在开始时固定使用一份路由表;热更新不会改变进行中的请求。

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 生产部署单节点或配置发布频率低本地 TOMLTLS、强制代理、加权多后端、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. 完整配置

下载:static-production.toml

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"
    }
  ]
}

下载:auth-snapshot.json

控制面接口要求:

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-fastclaude-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-key401,Provider 与代理均无请求
停止 egress proxy502,三个 Provider 均无直连
让备用后端 TLS 证书不可信仅在确认未发送字节时安全改选一次
让 Provider 返回 401客户端 502,该 backend 后续不再收流
停止 MQTTAPI 继续服务,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. 节点完整配置

下载:snapshot-node.toml

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:

下载:routing-snapshot-v41.json

{
  "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. 冷启动顺序

  1. 节点加载授权缓存;没有则保持 unready;
  2. 节点加载路由缓存;没有则 API 返回 503 not_ready
  3. auth 与 routing poller 分别拉取控制面;
  4. 两份有效快照都加载后 /readyz 返回 200;
  5. 流量入口才把节点加入服务集合。

示例健康响应:

{
  "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:

下载:routing-snapshot-v42.json

{
  "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
        }
      ]
    }
  }
}

推荐发布顺序:

  1. 控制面先把版本 42 设为当前完整快照;

  2. 再向 scootgate/prod/routing/notify 发布 QoS 1 消息:

    {"version": 42}
    
  3. 节点收到通知后等待 500ms 合并突发,再用 bearer token 拉取完整快照;

  4. /healthzrouting_snapshot_version 变为 42;

  5. 观察两个 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 的内容时:

  1. 复制版本 41 的 backends/models 内容;
  2. 把版本号设为 43;
  3. 先发布完整版本 43;
  4. 再发送 {"version":43} 通知;
  5. 确认所有节点健康面已切换。

这保证乱序、重复或延迟消息不会让节点状态倒退。

8. 故障与恢复

故障节点行为恢复
快照引用未知 credential整份拒绝,继续版本 41发布更高版本的有效完整快照
schema 不是 1整份拒绝修正 schema 并提高版本
收到版本 40/41 通知忽略,不拉取旧状态无需操作
MQTT 断线轮询继续,数据面不受阻重连后恢复通知
控制面短暂不可达旧表继续服务,仍 ready成功 200/304 后刷新 freshness
超过 300 秒不可达旧表继续服务,/readyz 非 200拉取成功后自动恢复 ready
新版本编译失败后发布 43版本 41 保留直到 43 成功原子切到 43

9. 多节点验收

对每个节点分别确认:

  • auth_snapshot_versionrouting_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/completionsOpenAI-compatible
POST/v1/messagesAnthropic Messages
GET/v1/modelsOpenAI-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-versionanthropic-beta 会转发给 Provider;客户端 x-api-key 会被 backend credential 替换。

能力过滤

ScootGate 从请求体推导所需能力:

请求字段所需能力
"stream": truesupports_stream
非空 tools(或非 array 值)supports_tools
response_format.type == "json_schema"supports_json_schema

模型存在但没有 target 同时满足能力时返回 400,不会出网。

请求头转发

客户端请求头采用 allowlist。只可能转发:

  • Content-Type
  • Accept
  • anthropic-version
  • anthropic-beta

ScootGate 重建 request line、Host、Provider 鉴权、Content-LengthConnection。客户端 Authorizationx-api-key、Host、代理头和其他 自定义头不会转发。

Provider 响应

除以下特殊处理外,ScootGate 转发 Provider 状态码、响应头和 body:

  • 移除 ConnectionKeep-AliveProxy-Connection, Proxy-AuthenticateTE
  • 添加 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 OKbody 为完整 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

字段要求

字段类型要求
versionu64大于 0 且严格大于当前版本
keysarray完整 key 集合
key_idstring非空
tenant_idstring非空
key_sha256string原始客户端 key 的 64 位 SHA-256 hex
statusstringactiverevoked

同一快照不得出现重复 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
        }
      ]
    }
  }
}

完整示例:

顶层要求

字段类型要求
schemau32固定为 1
versionu64严格大于已应用版本
backendsobject完整 backend map,至少一项
modelsobject完整公共 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 承载三类消息:

方向TopicQoS功能
ScootGate → broker{prefix}/usage1非权威用量事件
broker → ScootGate{prefix}/auth/notify1授权快照更新提示
broker → ScootGate{prefix}/routing/notify1路由快照更新提示

默认 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
}

字段

字段类型说明
schemau32固定 2
event_idstring稳定幂等键
instance_idstringScootGate 节点
tenant_id / key_idstring授权快照中的身份
providerstringopenai_compatibleanthropic_messages
pathstring客户端 Provider API 路径
statusu16返回客户端的状态码
outcomestring请求结果分类
public_modelstring/null客户端公共模型
upstream_modelstring/null改写后的 Provider 模型
backend_idstring/null最后选择/尝试的 backend
selection_attemptsu32总选择次数
attempted_backend_idsarray按顺序记录尝试
failure_stagestring/null失败阶段
send_statestringProvider 发送状态
charge_statestringtoken/计费不确定性
routing_versionu64snapshot 版本;static 模式为表 fingerprint
prompt_tokens / completion_tokensu64/nullProvider 原生 usage
request_body_bytes / response_body_bytesu64relay 字节计数
first_byte_msu64/nullProvider 首包延迟
duration_msu64请求总时长
ts_unix_msu64事件创建 Unix 毫秒时间

send_state

含义
not_sent选择前拒绝,未尝试 backend
definitely_not_sent尝试失败且能证明 Provider 请求字节未发出
possibly_sent请求可能到达 Provider,不能安全重试
response_received已收到 Provider 响应头

charge_state

含义
not_sentProvider 未收到请求
unknown可能发送/已响应,但 Provider 未报告 token usage
provider_reported至少一个 token 字段来自 Provider 原生 usage

缺失 token 必须是 JSON null,不能根据字节数估算。

常见 failure_stage

  • selection
  • reselect_exhausted
  • egress_connect
  • provider_tls
  • provider_write
  • response_head
  • provider_auth
  • null(正常 relay 或 Provider 429 等已收到响应场景)

常见 outcome

  • relayed
  • throttled
  • bad_request
  • unknown_model
  • unsupported_model_capability
  • no_backend_available
  • bad_gateway
  • gateway_timeout
  • upstream_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
  }
}
字段类型说明
servicestring固定 scootgate
instance_idstring节点配置 ID
versionstring二进制版本
statusstringalivestartingreadydraining
readybool当前能否接收新流量
auth_snapshot_versionu64当前授权版本;0 表示未加载
routing_snapshot_versionu64snapshot 路由版本;static 模式为 0
usage_outbox.pending_eventsu64未确认事件数
usage_outbox.pending_bytesu64未确认 payload 字节
usage_outbox.dropped_eventsu64进程生命周期累计丢弃数
usage_outbox.oldest_event_age_secsu64/null最老积压事件年龄

/healthz

只要进程和 health listener 能响应就返回 200。status 固定为 alive, 但 ready 仍反映真实就绪状态,因此调用方可以在一次请求中同时观察存活与就绪。

不要用 /healthz 决定是否接入 Provider 流量。

/readyz

只有以下条件全部成立才返回 200:

  1. auth_snapshot_version > 0
  2. 路由表可用;
  3. snapshot 路由最近一次成功同步未超过 max_stale_secs
  4. 节点未进入 draining。

否则返回 503 和相同 JSON schema。

状态解释

status/readyz含义
starting503缺授权/路由,或 snapshot 路由过期
ready200可接新流量
draining503收到停机信号,正在等待在途请求

Static 路由不会因控制面 staleness 变为 unready;授权快照目前只要求已加载 有效版本,不设置 staleness readiness 窗口。

探针建议

  • liveness:GET /healthz
  • readiness:GET /readyz
  • 周期应明显短于路由 max_stale_secs
  • 连续 readiness 失败后从入口摘除,但不要立刻杀死进程;
  • draining 立即停止分配新请求;
  • pending_bytesoldest_event_age_secsdropped_events 单独告警。

示例:

curl --fail --silent http://127.0.0.1:9091/healthz
curl --fail --silent http://127.0.0.1:9091/readyz