simple-ai-gateway
一个用 Rust 编写的轻量级 AI API 网关,在你的应用和上游模型供应商(OpenAI、Anthropic)之间提供统一的代理层,内置认证、限流、预算、缓存、重试和回退能力。
它能做什么
- 统一入口:把对 OpenAI、Anthropic 等供应商的调用,集中到一个网关后,统一管控密钥、配额、可观测性。
- 多租户与配额:按 Gateway API Key / 项目维度做 RPM、TPM、并发、成本预算限制。
- 韧性:可配重试、退避,以及在主供应商 5xx / 超时时自动切换到 fallback 供应商。
- 缓存:对相同请求体的响应做内存 + 磁盘/Redis 二级缓存。
- 审计:每个请求都落库,可通过 Admin API 检索。
- 可观测:Prometheus 指标 + 结构化 JSON 日志 + 可选 OTLP tracing。
适合谁用
- 团队/小组在多个项目之间共享 LLM 上游密钥,需要可见性与成本控制。
- 想给应用加一层 韧性 + 限流 + 审计,而不引入 Kong/Envoy 这种重型 API Gateway。
- 想要 OSS、可自托管、单二进制 / Docker 部署的方案。
不适合谁
- 需要对话级别的 prompt 改写、agent 编排、RAG。这是 LLM 网关,不是 LLM 框架。
- 需要内置每家供应商的 SDK 抽象。本网关做的是 透传 + 策略,客户端仍然按上游 API 形态发请求。
阅读建议
第一次接触建议按顺序读:
需要排查问题或对接监控时,看 可观测性 和 Admin API。
快速开始
5 分钟在本机起一个 Lite 模式网关,调通一次 OpenAI 请求。
前置条件
- Docker / Docker Compose, 或者 Rust 1.85+。
- 至少一个上游 API Key(OpenAI 或 Anthropic)。
1. 准备环境变量
cp .env.example .env
打开 .env,生成并填入两个必需 secret:
# 生成 admin root token
openssl rand -hex 32
# 生成加密用 master key
openssl rand -base64 32
填入 .env:
GATEWAY_ROOT_TOKEN=<上面 hex 输出>
GATEWAY_MASTER_KEY=<上面 base64 输出>
OPENAI_API_KEY=sk-...
⚠️
GATEWAY_MASTER_KEY用于加密落库的供应商凭证。丢了就无法解密历史数据,务必备份。
2. 启动网关
方式 A:Docker Compose (Lite)
docker compose -f docker-compose.lite.yml up --build
数据持久化到本地 ./data/gateway.db(SQLite)。
方式 B:Docker Compose (Standard)
会同时起 Postgres 和 Redis:
docker compose up --build
方式 C:本地 cargo
cargo run --release --bin gateway -- --config config/lite.yaml
启动后日志里能看到:
{"level":"INFO","fields":{"message":"gateway listening","addr":"0.0.0.0:8080","profile":"lite"}}
3. 创建一个 Gateway API Key
用 root token 调 Admin API:
curl -X POST http://localhost:8080/admin/keys \
-H "Authorization: Bearer $GATEWAY_ROOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"my-app","env":"live","scopes":["proxy"]}'
响应里的 secret 字段(形如 sk-gw-live-xxxxx)只显示这一次,务必保存。
4. 调一次模型
把 Gateway Key 当成上游 API Key 用,路径前加 /v1/{namespace}(namespace 通常就是 provider 名,除非你显式解耦):
curl http://localhost:8080/v1/openai/v1/chat/completions \
-H "Authorization: Bearer sk-gw-live-xxxxx" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-4o-mini",
"messages": [{"role":"user","content":"hello"}]
}'
网关会:
- 用 Argon2/BLAKE3 哈希验证 Gateway Key。
- 检查限流、预算。
- 用
OPENAI_API_KEY转发到https://api.openai.com/v1/chat/completions。 - 计算成本、落库一条请求日志,更新指标。
- 把上游响应原样返回。
5. 创建一个 Admin 账号(可选)
如果想用账号密码而不是 root token 登录 UI,先创建一个 admin。lite.yaml 默认已经开启 admin.password_login: true。
curl -X POST http://localhost:8080/admin/admins \
-H "Authorization: Bearer $GATEWAY_ROOT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
约束:
username非空,password≥ 6 位。
成功返回 {"id":"...","username":"admin"}。验证登录:
curl -X POST http://localhost:8080/admin/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin123"}'
响应里的 token 字段是 12 小时有效的 JWT,后续所有 /admin/* 请求都可以用它替代 root token。
6. 看一眼内置 UI
浏览器打开 http://localhost:8080/ui/,用刚才创建的账号密码登录(或粘贴 root token),可以看到 Key、日志、Cost 聚合。
接下来
配置说明
simple-ai-gateway 的运行时配置全部来自一个 YAML 文件,启动时通过 --config 或环境变量 GATEWAY_CONFIG 指定。本文档列出全部字段、默认值与可选取值,示例见 config/example.*.yaml。
文件根结构:
server: { ... }
storage: { ... }
admin: { ... }
providers: { ... }
routes: [ ... ]
limits: [ ... ]
budgets: [ ... ]
observability: { ... }
修改该文件会被自动监听并热重载,无需重启进程(由 gateway-api/reload 负责)。
环境变量
下列环境变量在启动时被读取,不在 YAML 中:
| 变量 | 必填 | 用途 |
|---|---|---|
GATEWAY_MASTER_KEY | 是 | 32 字节 base64,用于 Gateway Key 的 BLAKE3 keyed-hash 与 Admin JWT 签名。丢失即所有 Gateway Key 失效、所有已签发 JWT 失效。生成: openssl rand -base64 32。 |
GATEWAY_ROOT_TOKEN | 推荐 | Admin API 的初始 root token。变量名由 admin.root_token(如 env://GATEWAY_ROOT_TOKEN)指定,该字段为空则关闭 Admin API。 |
GATEWAY_CONFIG | 否 | 配置文件路径,等价于 --config,默认 config/lite.yaml。 |
GATEWAY_WORKERS | 否 | 仅在校验时使用;>1 且 storage 为 lite 时会拒绝启动。 |
RUST_LOG | 否 | tracing EnvFilter 表达式,默认 info,sqlx::query=warn。 |
OPENAI_API_KEY / ANTHROPIC_API_KEY / ... | 视配置 | 任何被 credential: env://VAR 引用的变量。 |
server
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
bind | string | 0.0.0.0:8080 | 监听地址,标准 host:port。 |
request_timeout_ms | u64 | 600000 | 单次请求总超时(毫秒),含上游往返。 |
default_project_id | string | default | 启动时自动 seed 的默认项目 ID;新 API Key 默认归属此项目。 |
storage
通过 profile 字段区分三种形态(serde tagged enum,profile 之外的字段按形态填):
profile: lite
单进程 SQLite + 进程内缓存。适合小规模 / 开发。
storage:
profile: lite
sqlite:
path: ./data/gateway.db
max_size_mb: 10240
log_retention_days: 30
cache:
l1_memory_mb: 256
l2_max_size_mb: 1024
| 字段 | 默认 | 说明 |
|---|---|---|
sqlite.path | ./data/gateway.db | 数据库文件路径。若在网络盘 (NFS/SMB) 上会发出警告,SQLite 锁不可靠。 |
sqlite.max_size_mb | 10240 | 软上限,用于日志清理触发参考。 |
sqlite.log_retention_days | 30 | 请求日志保留天数。 |
cache.l1_memory_mb | 256 | L1 内存缓存容量。 |
cache.l2_max_size_mb | 1024 | L2(SQLite)缓存容量。 |
注意:GATEWAY_WORKERS>1 与 lite 不兼容,网关会拒绝启动。
profile: standard
Postgres + Redis,可横向扩展。
storage:
profile: standard
postgres:
url: postgres://gateway:gatewaypass@postgres:5432/gateway
max_connections: 32
redis:
url: redis://redis:6379/0
cache:
l1_memory_mb: 256
l2_max_size_mb: 1024
| 字段 | 默认 | 说明 |
|---|---|---|
postgres.url | (必填) | sqlx 兼容连接串。 |
postgres.max_connections | 32 | 连接池大小。 |
redis.url | (必填) | Redis 连接串。 |
cache.l1_memory_mb | 256 | L1 内存缓存。 |
cache.l2_max_size_mb | 1024 | L2(Redis)缓存。 |
profile: memory
全内存,无持久化。重启即失,只用于测试。
storage:
profile: memory
cache:
l1_memory_mb: 64
l2_max_size_mb: 256
admin
| 字段 | 类型 | 默认 | 说明 |
|---|---|---|---|
root_token | string | env://GATEWAY_ROOT_TOKEN | root 权限的 Admin token。三种取值:""(空)→ 禁用 Admin API;env://VAR_NAME → 从环境变量读(未设或为空也视作禁用);其他字符串 → 直接当明文 token 用(与 gateway_keys[].secret 写明文同样的安全权衡,适合本地开发)。 |
password_login | bool | false | 是否启用用户名/密码登录(配合 /admin/auth/login)。 |
gateway_keys
可选数组,声明式预置一批 gateway API key。每次进程启动 + 配置热重载都会与库里 origin='config' 的行做一次同步:新增的写入、删除的丢弃、name/scopes 等元数据有变更则更新。
gateway_keys:
- id: prod-svc # 稳定 id,日志/限流/预算靠它关联
name: prod-svc
project_id: default # 可省略,默认取 server.default_project_id
secret: sk-gw-live-xxxxxxxxxxxxxxxx # 直接写明文,或 env://PROD_SVC_KEY 从环境变量读
scopes: [] # 可省
- id: ci-readonly
name: ci
secret: env://CI_GATEWAY_KEY
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 行 id,跨重载稳定。gateway_keys[] 内必须唯一。 |
name | string | 是 | 展示名,Admin 端列表里看到的就是它。 |
project_id | string | 否 | 落到哪个 project。省略则用 server.default_project_id。 |
secret | string | 是 | 明文 sk-gw-{live,test}-...,或 env://VAR_NAME。明文形态会用 derive_hash 哈希后落库,不会保留原文;env 形态延迟到 sync 时解析(变量缺失或为空会被打印到日志并跳过这一轮)。 |
scopes | string[] | 否 | 与 Admin API 创建的 key 含义相同,默认 []。 |
约束与行为:
- 启动期校验:
id唯一、name/secret非空,明文 secret 必须带sk-gw-live-或sk-gw-test-前缀。 - Admin API 拒绝 revoke
origin='config'的 key(返回 400,提示从 YAML 移除)。UI 列表里这些 key 带config徽标且没有 Revoke 按钮。 - 配置文件里移除某条 key 后下一次热重载会从库里删除它(此后该明文 key 无法再认证)。要"暂时禁用",直接从 YAML 删掉即可。
- 明文 secret 会被 redacted 处理,不会出现在
Debug日志里 —— 但 YAML 在磁盘上是明文,操作时注意文件权限;若想做最小权限注入,把 secret 写成env://...。
providers
map<string, ProviderConfig>,key 是上游身份名(被 routes[].primary.provider 和 fallbacks[].provider 引用)。URL /v1/{namespace}/* 中的 namespace 是另一个独立概念,默认与该 key 同名,可通过 match.namespace 解耦。
providers:
openai: # key = openai → 隐式 kind = openai
base_url: https://api.openai.com
credential: env://OPENAI_API_KEY
headers:
X-Custom: value
anthropic:
base_url: https://api.anthropic.com
credential: env://ANTHROPIC_API_KEY
doubao: # 任意名字
kind: openai # ← 显式声明走 OpenAI 协议
base_url: https://ark.cn-beijing.volces.com/api/v3
credential: env://DOUBAO_API_KEY
| 字段 | 必填 | 说明 |
|---|---|---|
kind | 否 | Auth adapter 类型。未设置时,落到 map 里的 key 名(只有那些 key 名等于内置 adapter 名的配法才能省略)。已支持的取值见下。 |
base_url | 是 | 上游基础 URL,客户端路径会拼在后面。 |
credential | 是 | 上游 API key 来源。env://VAR_NAME 在启动期读环境变量;任何其他字符串作为字面量直接当 token(YAML 里写明文,适合本地开发,生产建议走 env://)。 |
headers | 否 | 透传给上游的额外固定 header。 |
支持的 kind
| 值 | 适用上游 | Auth 行为 |
|---|---|---|
openai | OpenAI 官方,以及任何沿用 OpenAI 协议的第三方(豆包、DeepSeek、Together、Groq、Mistral、Azure OpenAI、vLLM、Ollama、LM Studio…) | Authorization: Bearer <key> + api-key: <key> 双 header |
anthropic | Anthropic | x-api-key: <key>,并在 client 未设置时注入 anthropic-version: 2023-06-01 |
未设置 kind 时,系统直接拿 providers map 的 key 名去匹配上表 —— 所以历史配法 providers.openai: { ... }、providers.anthropic: { ... } 仍然有效。但只要 key 名不是 openai / anthropic 之一,就必须写 kind,否则启动期 validate 拒绝加载。
接入一个新的 OpenAI 兼容供应商
只需要一条配置,不需要改任何代码:
providers:
deepseek:
kind: openai
base_url: https://api.deepseek.com
credential: env://DEEPSEEK_API_KEY
groq:
kind: openai
base_url: https://api.groq.com/openai
credential: env://GROQ_API_KEY
local-vllm:
kind: openai
base_url: http://10.0.0.5:8000
credential: env://VLLM_TOKEN
如果上游需要额外固定 header(比如 Azure OpenAI 的 api-version 查询参数走 header,或自定义路由 key),写在 headers: 里即可。如果上游使用完全不同的 auth 协议(非 OpenAI、非 Anthropic),才需要在 crates/gateway-core/src/providers/ 加一个新 adapter,详见 开发指南。
routes
每条路由把"哪些请求"绑定到"上游身份和代理策略(缓存、重试、回退)"。按数组顺序匹配,第一条同时满足以下条件的命中:
- URL 中的
/v1/{namespace}/...段等于match.namespace(必填,启动时校验); - 若
match.model_prefix有值,请求体里的model字段以该前缀开头。
注意:namespace(URL 段)与 primary.provider(providers 表里的 key)是两个独立概念。命名相同是约定,不是要求。让它们不同,就能实现"客户端继续叫 /v1/openai/...,但后端偷偷转发给 azure"这类场景。
providers:
openai: # 上游身份:实际网络目的地 + 凭证
base_url: https://api.openai.com
credential: env://OPENAI_API_KEY
azure-prod:
base_url: https://prod.openai.azure.com
credential: env://AZURE_KEY
routes:
# 透明迁移:客户端用的还是 /v1/openai/...,网关后端走 azure
- match:
namespace: openai
model_prefix: gpt-
primary:
provider: azure-prod # ← 与 namespace 不同
cache: { enabled: true, ttl: 3600 }
# 兜底:其他 openai 请求(o1- 等)继续走真正的 OpenAI,默认无缓存
- match:
namespace: openai
primary:
provider: openai
没有匹配的路由(或 routes: [])时:网关直接返回 404(错误码 not_found)。namespace 必须显式登记在 routes[] 里才能被代理 —— 最简写法:
- match: { namespace: openai }
primary: { provider: openai }
这样 /v1/openai/... 就会按默认重试 3 次/500ms 退避、无缓存、无 fallback 的方式转发到 providers.openai。启动时如果发现某条 route 缺 match.namespace,网关会直接拒绝加载配置。
match
| 字段 | 默认 | 说明 |
|---|---|---|
namespace | 必填(无默认) | URL /v1/{namespace}/... 段。不是 providers 表的 key,只是对外暴露的别名。即使想用"namespace = provider"的一对一配法,也必须显式写出 match.namespace,启动时校验不通过会拒绝加载。 |
model_prefix | None | 请求体 model 字段的字符串前缀。例:gpt- 匹配 gpt-4o-mini、gpt-3.5-turbo 等。若设置了 model_prefix 但请求没有 model 字段(GET /v1/models 等),视为不匹配。 |
如果同一 namespace 配多套策略,把更具体的放在数组前面,兜底的放后面。
primary / fallbacks (RouteTarget)
| 字段 | 必填 | 说明 |
|---|---|---|
provider | 是 | 必须存在于 providers 表,否则启动失败。 |
model | 否 | 改写请求体的 model,可用于将外部模型名映射到供应商内部名。 |
trigger | 否 | 仅 fallback 使用;空数组等于"总是触发"。可选值见下。 |
trigger 取值(参考 crates/gateway-core/src/proxy/retry.rs):
| 值 | 触发场景 |
|---|---|
upstream_5xx / upstream_error | 上游返回 5xx,或不可重试的服务端错误。 |
timeout | 请求超时,或可重试的网络错误。 |
rate_limited | 上游 429。 |
network | 一般性可重试网络错误。 |
cache
| 字段 | 默认 | 说明 |
|---|---|---|
enabled | false | 是否对该路由启用响应缓存。 |
ttl | 3600 | 缓存秒数。 |
allow_nondeterministic | false | 为 true 时,即使请求不是确定性采样(temperature != 0 或 top_p < 0.999)也写入/读取缓存。与请求头 X-Gateway-Cache-Force 按"或"关系合并 —— 任一为真即放行。 |
缓存 key 来自请求体 + 路径的 blake3 摘要。流式响应也会被缓存(replay 时按原 chunk 边界发回),前提是请求体确定性(temperature == 0 且 top_p >= 0.999)且大小 ≤ 20 MB。要绕开确定性检查,可在路由上设置 allow_nondeterministic: true,或在请求头加 X-Gateway-Cache-Force: 1。
retry
| 字段 | 默认 | 说明 |
|---|---|---|
max_attempts | 3 | 同一目标上的最大尝试次数(含首次)。 |
initial_backoff_ms | 500 | 初始退避,按指数增长。 |
limits
数组,每条规则独立检测;命中任意一条即拒绝请求(HTTP 429)。
limits:
- target: { type: key, id: "*" }
rpm: 1000
tpm: 200000
concurrency: 50
- target: { type: project, id: default }
rpm: 5000
- target: { type: global }
concurrency: 200
target
type | id 语义 |
|---|---|
key | 匹配 Gateway API Key 的 ID;"*" 表示所有 Key。 |
project | 匹配项目 ID;"*" 表示所有项目。 |
global | 全局,无视 id。 |
metadata | 预留,目前未启用。 |
度量字段(都可省略)
| 字段 | 含义 |
|---|---|
rpm | Requests per minute 上限。 |
tpm | Tokens per minute 上限(基于请求中估算或上游返回的 token 数)。 |
concurrency | 同时在飞的请求数上限。 |
budgets
数组,每条独立累计。命中 block 阈值会拒绝后续请求(HTTP 402),notify 阈值会触发 webhook。
budgets:
- name: monthly-team-budget
target:
project_id: default
# 或 gateway_key_id: xxx
period: monthly
amount_usd: 500.0
thresholds:
- { at: 0.8, action: notify, webhook: https://hooks.example.com/budget }
- { at: 1.0, action: block }
| 字段 | 说明 |
|---|---|
name | 预算唯一名,用于计数 key 与日志。 |
target.project_id | 与 gateway_key_id 二选一;不填则不会累计任何流量。 |
target.gateway_key_id | 限定到具体 Key。 |
period | daily / weekly / monthly,UTC 边界。未知值按 monthly 处理。 |
amount_usd | 周期总预算,单位美元。 |
thresholds[].at | 0–1 的百分比阈值。 |
thresholds[].action | notify(发 webhook,默认幂等) / block(拒绝请求)。 |
thresholds[].webhook | 仅 notify 时使用,HTTP POST 一个 JSON 负载。 |
成本由启动时通过 --pricing-catalog(或环境变量 GATEWAY_PRICING_CATALOG)指定的 pricing-catalog.json 计算,未知模型按 0 计。默认读取工作目录下的 pricing-catalog.json。
observability
observability:
metrics: true
tracing:
enabled: true
format: json
otlp_endpoint: null
| 字段 | 默认 | 说明 |
|---|---|---|
metrics | true | 是否暴露 Prometheus /metrics 端点。 |
tracing.enabled | true | 是否开启 tracing。 |
tracing.format | json | json 或 text,影响 stdout 日志格式。 |
tracing.otlp_endpoint | null | 可选 OTLP HTTP/gRPC 端点。 |
校验规则
启动时 AppConfig::validate() 会做以下检查,失败立即退出:
- 所有
routes[].primary.provider必须在providers中存在。 - 所有
routes[].fallbacks[].provider必须在providers中存在。
其他不一致(如未知 limit.target.type)在运行时被忽略而非拒绝,便于在多版本间平滑过渡。
代理 API
应用方调网关的接口。形态上是 完全透传 —— 网关只在中间做认证、限流、改写凭证、记录日志、按需缓存/回退,不做任何上游 API 的封装或参数翻译。
路径形态
/v1/{namespace}/{...upstream_path}
{namespace}是 route 的对外别名。可以与某个providers表里的 key 同名(常见 1:1 配法,无需写match.namespace),也可以独立命名,通过match.namespace显式绑定到一个 provider(透明迁移场景)。{...upstream_path}原样拼到命中的 provider 的base_url后。
例如:
| 客户端调用 | 网关转发到 |
|---|---|
POST /v1/openai/v1/chat/completions | POST https://api.openai.com/v1/chat/completions |
POST /v1/anthropic/v1/messages | POST https://api.anthropic.com/v1/messages |
GET /v1/openai/v1/models | GET https://api.openai.com/v1/models |
认证
请求头携带 Gateway API Key:
Authorization: Bearer sk-gw-live-xxxxxxxxxxxx
网关会:
- 用 BLAKE3-keyed-hash 算出 hash,常时间对比数据库里的 hash。
- 检查 Key 状态(
active/revoked)和expires_at。 - 异步更新
last_used_at。
不会把这个 header 透传给上游 —— 网关会把它换成 providers.{name}.credential 解出的真实上游凭证。
请求体
直接按上游格式写,网关不做改写。但有两点会被网关读取:
model字段 — 用来做routes[].match.model_prefix匹配,以及计入成本日志。- 整个 body 的 blake3 摘要 — 用作响应缓存的 key(若该路由
cache.enabled: true)。
流式响应
如果请求带 stream: true(SSE/chunked),网关会以流式方式回给客户端,不做整体缓冲(chunk 边发边转)。
流式响应会被缓存(若该路由 cache.enabled 且其他条件满足):chunks 在转发的同时被记录,流正常结束后整段写入 KV;命中时用 Body::from_stream 按原 chunk 边界 replay,客户端看到的 SSE 序列与首次一致。
流式的 token 用量在最后一帧到达时统计写入日志。
头部处理
| 类型 | 行为 |
|---|---|
Authorization | 替换为上游真实凭证。 |
Host | 改写到上游主机名。 |
User-Agent | 透传,并写入日志。 |
Content-Type | 透传。 |
配置里的 providers.{name}.headers | 追加到上游请求(覆盖同名)。 |
上游响应 Content-Length / Transfer-Encoding | 由网关重新计算。 |
| 其他 | 透传。 |
路由选择
按数组顺序逐条评估,第一条同时满足以下条件的路由被选中:
- URL
/v1/{namespace}/...段等于match.namespace(未显式设置时,默认取primary.provider) - 若
match.model_prefix有值,请求体model字段以该前缀开头(没有model字段视为不匹配)
namespace 是对外暴露的 URL 段,primary.provider 是 providers 表里的 key —— 是两个独立概念,允许同一个 namespace 在后端切换到不同 provider。详见 配置参考 > routes。命中后:
- 检查
cache.enabled,且请求体是确定性的(temperature == 0且top_p >= 0.999,或路由配了cache.allow_nondeterministic: true,或带X-Gateway-Cache-Force头),则查缓存,命中则直接返回,响应头加X-Gateway-Cache-Status: hit。 - 否则按
primary转发,失败重试至retry.max_attempts次。 - 仍失败且
trigger命中 → 切到下一个fallbacks[]。 - 成功响应若满足缓存条件(≤ 20 MB、状态 2xx),写回 KV;流式响应也会缓存,replay 时保留 chunk 边界。
- 写日志,返回响应。
没有匹配的路由时,网关直接返回 404(错误码 not_found),不会再把 namespace 当作 providers 表的 key 做兜底转发。想暴露 /v1/<name>/...,必须在 routes[] 里写一条对应的条目(最简写法 - primary: { provider: <name> })。
响应头
网关附加的特殊头(其他都透传):
| Header | 含义 |
|---|---|
X-Gateway-Request-Id | 本次请求的内部 ID,与 /admin/logs 中的 id 对应。 |
X-Gateway-Cache-Status | hit / miss / refresh / bypass。 |
X-Gateway-Cache-Key | 命中/写入的缓存 key 前缀(截断到 48 字符,便于排障)。 |
X-Gateway-Cache-Age | 仅 HIT 时出现,缓存条目年龄(秒)。 |
X-Provider | 实际命中的供应商(可能是 fallback)。 |
错误码
| HTTP | 场景 |
|---|---|
401 | 缺少 Authorization 或 Key 无效 / 已撤销 / 已过期。 |
402 | 命中 budgets[].thresholds[].action: block。 |
404 | URL 中的 {namespace} 没有对应的 route。 |
408 | 请求超时(server.request_timeout_ms 触发)。 |
429 | 命中 limits[] 中的 RPM / TPM / 并发上限。 |
502 | 上游所有重试 + fallback 都失败。 |
504 | 上游超时,且无可用 fallback。 |
错误响应体格式:
{
"error": {
"code": "rate_limited",
"message": "request exceeded RPM limit"
}
}
健康检查 / 指标
| 路径 | 用途 |
|---|---|
GET /healthz | 进程存活,总是 200 OK。 |
GET /readyz | 数据库 + 上游可达性检查,失败返回 503。 |
GET /metrics | Prometheus text 格式指标,详见 可观测性。 |
这些路径无需认证。
Admin API
所有 Admin 端点挂在 /admin 前缀下,统一通过 Authorization: Bearer <token> 鉴权。Token 可以是两种之一:
- Root token — 启动时从
GATEWAY_ROOT_TOKEN环境变量读取,长期有效,拥有所有权限。运维侧使用。 - Admin JWT — 通过
/admin/auth/login用用户名密码换取,默认 TTL 12 小时。UI 登录场景使用。
错误响应统一为:
{ "error": { "code": "unauthorized", "message": "..." } }
下文每张表中的"权限":Any 表示两种 token 都可用。目前没有更细粒度的角色划分。
鉴权 /admin/auth
POST /admin/auth/login
用用户名密码换 JWT。仅在 admin.password_login: true 时启用。
请求:
{ "username": "alice", "password": "..." }
响应:
{
"token": "<jwt>",
"username": "alice",
"expires_at": 1715760000
}
GET /admin/auth/me
返回当前 token 对应的 principal。
权限:Any。
响应:
{ "principal": "user", "username": "alice", "id": "adm_..." }
principal 取值为 root 或 user。
POST /admin/admins
创建一个 admin 用户(密码用 Argon2 哈希落库)。
权限:Any。
请求:
{ "username": "alice", "password": "strong-passphrase" }
字段约束:username 非空,password ≥ 6 位。不满足返回 400 bad_request。
响应:
{ "id": "adm_...", "username": "alice" }
GET /admin/admins
列出所有 admin。
权限:Any。
响应:
[
{
"id": "adm_...",
"username": "alice",
"created_at": 1715000000,
"last_login_at": 1715760000
}
]
API Keys /admin/keys
POST /admin/keys
创建一个 Gateway API Key。明文只在响应里出现一次,落库的只是 BLAKE3 keyed hash。
权限:Any。
请求:
{
"name": "my-app-prod",
"env": "live",
"project_id": "default",
"scopes": ["proxy"],
"expires_at": 1735689600
}
| 字段 | 必填 | 说明 |
|---|---|---|
name | 是 | 显示用,任意字符串。 |
env | 是 | live 或 test,只影响 key 的前缀(sk-gw-live- / sk-gw-test-),目前不参与认证、路由、限流、预算等任何逻辑。仅作为肉眼可见的标签,用于区分生产与测试 key(借鉴 Stripe 的命名约定)。 |
project_id | 否 | 默认 server.default_project_id。 |
scopes | 否 | 默认 ["proxy"]。目前 scope 不参与执行,占位。 |
expires_at | 否 | unix 秒。null 表示不过期。 |
响应:
{
"id": "key_...",
"name": "my-app-prod",
"prefix": "sk-gw-live-",
"last4": "a1b2",
"secret": "sk-gw-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"created_at": 1715760000
}
GET /admin/keys
列出 Key(不返回明文)。
权限:Any。
查询参数:project_id(可选,按项目过滤)。
响应:
[
{
"id": "key_...",
"project_id": "default",
"name": "my-app-prod",
"prefix": "sk-gw-live-",
"last4": "a1b2",
"status": "active",
"created_at": 1715760000,
"last_used_at": 1715800000,
"expires_at": null,
"origin": "admin"
}
]
status 取值:active / revoked。origin 取值:admin(由本 API 创建)或 config(由 gateway_keys[] 预置,见 配置参考 > gateway_keys)。
DELETE /admin/keys/:id
撤销 Key(软删,status 变为 revoked)。origin 为 config 的 key 不能从这里撤销 —— 接口会返回 400 bad_request,需要在 YAML 里把这条记录删掉再 reload。
权限:Any。
响应:
{ "id": "key_...", "status": "revoked" }
请求日志 /admin/logs
GET /admin/logs
分页检索请求日志。
权限:Any。
查询参数(都可选):
| 参数 | 类型 | 说明 |
|---|---|---|
project_id | string | 按项目过滤。 |
gateway_key_id | string | 按 Key 过滤。 |
namespace | string | 按 URL namespace 过滤(对应 /v1/{namespace}/... 段)。 |
model | string | 按模型名过滤(精确)。 |
status | string | ok / gateway_error / upstream_error。 |
from / to | int | unix 秒,时间范围。 |
limit | int | 默认 50,上限 200。 |
cursor | string | 上一页响应里的 next_cursor。 |
响应:
{
"items": [ { "id": "log_...", "...": "..." } ],
"next_cursor": "..."
}
GET /admin/logs/:id
返回单条日志的完整 JSON(包含请求/响应 body 摘要,大于 64KB 部分会被截断)。
配置只读 /admin/routes
GET /admin/routes
返回当前生效的 AppConfig(脱敏后),用于 UI 展示。配置改动会被自动重载,这里读到的总是最新版本。
权限:Any。
成本聚合 /admin/cost
GET /admin/cost
按维度聚合 token 用量与美元成本。
权限:Any。
查询参数:
| 参数 | 说明 |
|---|---|
project_id | 限定项目。 |
from / to | 时间范围(unix 秒)。 |
group_by | namespace / model / gateway_key_id / day 之一(或逗号分隔的组合)。 |
响应:
{
"rows": [
{
"key": "openai",
"input_tokens": 12345,
"output_tokens": 6789,
"cost_usd": 0.12
}
],
"total_usd": 0.12
}
预算 /admin/budgets
GET /admin/budgets
返回当前所有预算的实时用量。
权限:Any。
响应:
[
{
"budget_id": "monthly-team",
"name": "monthly-team",
"period": "monthly",
"period_start": 1714521600000,
"amount_usd": 500.0,
"used_usd": 87.3,
"pct": 0.1746,
"blocked": false
}
]
blocked: true 表示已跨过 action: block 阈值,后续请求会被拒(HTTP 402)。
调用示例
用脚本批量创建 Key:
for i in 1 2 3; do
curl -s -X POST http://localhost:8080/admin/keys \
-H "Authorization: Bearer $GATEWAY_ROOT_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"name\":\"bot-$i\",\"env\":\"live\"}" \
| jq '{name, secret}'
done
架构概览
进程结构
simple-ai-gateway 是单二进制 (gateway) 的 Rust 程序,内部分为四个 crate:
| Crate | 职责 |
|---|---|
gateway-core | 配置、代理引擎、供应商适配、缓存、定价、安全(master key / Argon2 / JWT)。 |
gateway-storage | MetadataStore / LogStore / KvStore / CounterStore 抽象,以及 SQLite、Postgres、Redis、内存四套实现。 |
gateway-api | axum 路由、Admin API、Gateway Key 认证、限流、预算、配置热重载。 |
gateway-bin | 进程入口:加载配置、初始化 stores、装配 ProxyEngine、启动 HTTP 服务。 |
gateway-ui | 内置的最简 Admin UI(include_str! 注入)。 |
请求生命周期
一次代理请求(/v1/{namespace}/...)经过的环节,按顺序:
- 认证 (
auth.rs) — 从Authorization头取 Gateway Key,BLAKE3 keyed-hash 后查表,常时间对比,核对status与expires_at。 - 读 body (
routes/proxy.rs) — 整体读入内存(流式响应除外),计算 blake3 摘要,提取model字段。 - 预算检查 (
budget.rs) — 命中action: block阈值则直接 402。 - 限流 (
ratelimit.rs) — 按limits[]配置依次检查 RPM / TPM / 并发。 - 路由匹配 — 按数组顺序找第一条满足
match.namespace == URL段(未设置时默认primary.provider)且match.model_prefix(若设置)命中的路由。namespace是对外暴露的 URL 段,primary.provider才是providers表的 key,两者解耦。没有匹配则用ProviderChain::primary_only,把 URL 段直接当作 provider 名(无缓存、无 fallback、默认重试)。 - 缓存查找 — 若
cache.enabled,以(provider, path, body_blake3)为 key 查 L1(内存)→ L2(SQLite/Redis)。命中则直接返回,X-Cache: HIT。 - 构造 forward request — 改写凭证、Host,追加
providers.headers。 - 执行链 (
proxy/executor.rs) — 对primary尝试retry.max_attempts次(指数退避);失败若命中fallback.trigger则切换到下一个 target,直到耗尽。 - 写缓存 + 落日志 — 非流式且可缓存的响应写回缓存;不论成败都通过
LogStore异步落库。 - 更新预算与指标 — 用上游返回的 token usage 算成本,累加到
BudgetManager,bump Prometheus counter/histogram。 - 返回响应 — 透传 status / headers / body,附加
X-Gateway-Request-Id、X-Cache、X-Provider。
存储抽象
StoreBundle 把四种 store 组合在一起,供 API 层使用:
| Store | Lite (SQLite + Memory) | Standard (Postgres + Redis) | Memory (测试) |
|---|---|---|---|
MetadataStore | SQLite | Postgres | 进程内 |
LogStore | SQLite(异步批量写) | Postgres(异步批量写) | 环形缓冲 10k |
KvStore (cache) | 内存 L1 + SQLite L2 | 内存 L1 + Redis L2 | 内存 |
CounterStore | 内存 | Redis | 内存 |
Lite 模式下 CounterStore 是进程内 —— 这就是为什么 GATEWAY_WORKERS>1 与 lite 不兼容:多进程之间无法共享 RPM/TPM/并发计数。
配置热重载
gateway-api/reload.rs 用 notify 监听配置文件变化:
- 文件变更触发再次
AppConfig::load_from_path+validate。 - 验证通过则
ArcSwap::store替换当前 config 快照。 - 失败则保留旧配置,记录
gateway_config_reload_error_total。
热重载范围:全部字段。ProxyEngine 持有的 reqwest client 不重建,但 routes / providers 的 base_url、credentials、limits、budgets、cache TTL 都会立即生效。
server.bind 改了不会生效(已经在监听)。
缓存设计
L1 (moka) 在所有 profile 下都是进程内 LRU,容量由 cache.l1_memory_mb 控制。
L2:
- Lite:与元数据同库,放在 SQLite 的
kv表,按l2_max_size_mb做粗粒度淘汰。 - Standard:Redis,key 形如
cache:<fingerprint>,带 TTL。 - Memory:无 L2。
响应 body 超过 20 MB 不写缓存(MAX_CACHEABLE_BODY_BYTES),避免污染。
流式响应也会被缓存:转发途中 chunk 被同时累积进 cache_chunks,流正常结束后整段写入 KV。命中时 build_cached_response 用 Body::from_stream 按原 chunk 边界 replay,所以 SSE 客户端看到的事件流跟首次一致。缓存写入默认需要请求体确定性(temperature == 0 且 top_p >= 0.999),否则一律 bypass;路由可设 cache.allow_nondeterministic: true 跳过此检查(等价于全局打开 X-Gateway-Cache-Force)。
重试 / 回退
proxy/executor.rs 把 primary + fallbacks 当成一条链,每个 entry 各自有 retry.max_attempts:
- 同一 entry 内的失败按指数退避重试。
- 切换到下一个 entry 前,先看上一个 entry 的
AttemptOutcome是否在当前 entry 的trigger集合中;不匹配就直接返回错误。 trigger为空数组表示"无条件接管"。
可能的 AttemptOutcome:Success / UpstreamError(status) / Timeout / RetryableNetwork,以及 RateLimited(429)。
凭证解析
provider.credential 在构造 proxy engine 时一次性解析,结果缓存在 ResolvedProvider.api_key:
env://VAR_NAME—std::env::var,变量未设直接报启动失败。- 任何其他字符串 — 当作字面量 token 用,不做任何转换。
凭证存活到下次进程重启;轮换上游 key 需要改 YAML 或环境变量后重启 / 重载 provider。
关键非目标
- 不做 prompt 翻译/改写 —— 网关只搬运字节流。
- 不做模型路由(按 prompt 内容选模型) —— 只按路径 + model_prefix 静态路由。
- 不实现 SDK 抽象 —— 客户端继续按上游 SDK 写。
部署指南
形态选择
| 维度 | Lite | Standard |
|---|---|---|
| 元数据 / 日志 | SQLite | Postgres |
| 计数器 (RPM/TPM/并发) | 进程内内存 | Redis |
| L2 缓存 | SQLite | Redis |
| 副本数 | 1(进程内计数无法跨副本) | 多副本 |
| 部署难度 | 单容器,挂卷即可 | 三组件 + 健康检查 |
| 数据迁移 | 卷迁移 | pg_dump |
| 适用 | 个人 / 小团队 / 单机 | 团队 / 多副本 / 生产 |
切换 profile 需要重启进程,数据不会自动迁移;先备份再换。
Docker
仓库自带两份 compose:
# Lite,本地 SQLite,数据落 ./data
docker compose -f docker-compose.lite.yml up -d
# Standard,起 Postgres + Redis
docker compose up -d
Dockerfile 是两阶段构建,基镜像 debian:bookworm-slim,运行时只装 ca-certificates libssl3 sqlite3,镜像约 50 MB。
容器内默认 EXPOSE 8080,VOLUME /app/data(Lite 模式下挂这个)。
必需环境变量
任何形态都需要:
| 变量 | 说明 |
|---|---|
GATEWAY_MASTER_KEY | base64 编码 32 字节。Gateway Key 的 BLAKE3 keyed-hash 和 Admin JWT 都靠它。生成: openssl rand -base64 32。 |
GATEWAY_ROOT_TOKEN | Admin API 初始凭证。生成: openssl rand -hex 32。变量名可改,通过 admin.root_token: env://<NAME> 指定。 |
上游凭证按 YAML 中 credential 决定:env://VAR 引用环境变量,任何其他字符串当字面量直接用。
Kubernetes 提示
没有自带 chart,自行写 Deployment 时注意:
- 副本数:Lite 模式
replicas: 1,且配strategy.type: Recreate(避免两个 pod 同时写一个 SQLite 文件)。Standard 可任意横向扩展。 - Secret 注入:
GATEWAY_MASTER_KEY和GATEWAY_ROOT_TOKEN都用 K8s Secret,通过envFrom: secretRef。 - 存储:Lite 必须挂 PVC 到
/app/data;Standard 不需要 PVC。 - 探针:liveness 用
/healthz,readiness 用/readyz。 - 资源:基线 50m CPU / 128Mi memory,缓存命中场景下大致够用;预留 L1 缓存的
cache.l1_memory_mb用量(默认 256MB)。
配置文件管理
配置改动会热重载(gateway-api/reload.rs 用 notify 监听文件 inode 变化)。
- Docker:
-v $(pwd)/config:/app/config:ro,然后改本地文件即可。 - K8s: 用 ConfigMap +
subPath注入,改 ConfigMap 后 pod 内文件会异步刷新。建议把 ConfigMap 改动配合kubectl rollout restart以确保安全。
热重载失效的字段:server.bind(已经在监听)、storage.profile(stores 已经初始化)。
Master Key 轮换
目前没有内置轮换机制。要换 master key 必须:
- 停服,导出 DB。
- 用新 key 启动空库,重新创建 admin 用户和 gateway key(已签发的 Admin JWT 也会失效)。
- 把日志库恢复(日志不加密)。
因此生产场景强烈建议在密钥管理系统(AWS KMS / GCP Secret Manager / Vault)里存 master key,并把它视为同等级别的根密钥。
备份
| 数据 | 形态 | 备份方式 |
|---|---|---|
| Lite SQLite | ./data/gateway.db | sqlite3 .backup 或冷拷贝(停服) |
| Standard Postgres | volume | pg_dump |
| Standard Redis | volume(带 AOF) | RDB / AOF 持久化(本仓库 compose 已开 --appendonly yes) |
Master key 必须单独备份,否则上面的备份都还原不出加密凭证。
生产 checklist
-
GATEWAY_MASTER_KEY在 KMS 中,本地无明文副本。 -
GATEWAY_ROOT_TOKEN已轮换为强随机值,只给运维使用;日常通过 Admin JWT 操作。 -
配置文件里的
admin.password_login仅在确实有 UI 用户时打开。 -
limits[]至少有一条target.type: key, id: "*"兜底,防止单 Key 拖死整个网关。 -
routes[].cache对幂等的只读端点(/v1/models等)开启,对 chat completions 谨慎开启(prompt 一字不差才命中,但缓存"对话历史"是非预期行为)。 - Postgres + Redis 都设了密码,且不暴露到公网。
-
Prometheus 抓取
/metrics,告警至少覆盖gateway_ratelimit_hit_total突增和gateway_config_reload_error_total > 0。 -
日志聚合(Loki / ELK / CloudWatch)接好;
RUST_LOG不要长期开debug,JSON 日志量会很大。 - 在前面挂一层 TLS 终端(Caddy / nginx / cloud LB);网关本身不监听 TLS。
可观测性
网关暴露三类信号:Prometheus 指标、结构化日志、按需 OTLP tracing。
健康检查
| 路径 | 含义 | 失败行为 |
|---|---|---|
GET /healthz | 进程在跑就返回 200,不查依赖。用于 K8s liveness。 | - |
GET /readyz | 检查 DB / Redis 可达性。用于 K8s readiness。 | 任意依赖不可达返回 503。 |
两个端点无需认证。
Prometheus 指标
GET /metrics 返回 Prometheus text 格式,需要在 YAML 里 observability.metrics: true(默认开)。
代理与缓存
| 指标 | 类型 | Labels | 含义 |
|---|---|---|---|
gateway_cache_hit_total | counter | tier={l1|l2} | KV 缓存命中次数(底层 KvStore)。 |
gateway_cache_miss_total | counter | - | KV 缓存未命中。 |
gateway_cache_write_total | counter | - | KV 缓存写入。 |
gateway_cache_response_hit_total | counter | - | 路由级响应缓存命中(用户视角)。 |
gateway_cache_response_write_total | counter | - | 路由级响应缓存写入。 |
限流与预算
| 指标 | 类型 | Labels | 含义 |
|---|---|---|---|
gateway_ratelimit_hit_total | counter | kind={rpm|tpm|concurrency} | 触发限流的次数。 |
gateway_budget_pct | histogram | budget=<name> | 每次请求后预算使用率(0–1)。 |
gateway_budget_threshold_total | counter | budget=<name>, action={alert|block} | 阈值跨越次数。 |
日志写入
| 指标 | 类型 | Labels | 含义 |
|---|---|---|---|
gateway_log_write_total | counter | - | 日志条目成功落库。 |
gateway_log_write_error_total | counter | - | 日志落库失败。 |
gateway_log_drop_total | counter | reason=full | 异步队列满,日志被丢弃。 |
配置热重载
| 指标 | 类型 | Labels | 含义 |
|---|---|---|---|
gateway_config_reload_total | counter | - | 配置热重载成功次数。 |
gateway_config_reload_error_total | counter | - | 配置热重载失败次数。持续 > 0 必报警。 |
推荐告警
# 持续限流(可能是上游限速或恶意流量)
rate(gateway_ratelimit_hit_total[5m]) > 1
# 日志写入失败累积
increase(gateway_log_write_error_total[10m]) > 0
# 配置加载失败
increase(gateway_config_reload_error_total[5m]) > 0
# 任何预算跨过 80% 警告线
increase(gateway_budget_threshold_total{action="alert"}[1h]) > 0
# 任何预算跨过 100% 拦截线
increase(gateway_budget_threshold_total{action="block"}[1h]) > 0
结构化日志
进程 stdout 输出 tracing JSON。字段示意:
{
"timestamp": "2026-05-15T03:21:11.234Z",
"level": "INFO",
"fields": {
"message": "proxy request completed",
"request_id": "req_...",
"project_id": "default",
"gateway_key_id": "key_...",
"namespace": "openai",
"model": "gpt-4o-mini",
"status": 200,
"input_tokens": 12,
"output_tokens": 64,
"cost_usd": 0.00031,
"elapsed_ms": 842,
"upstream_ms": 800,
"queue_ms": 12,
"cache": "MISS",
"outcome": "primary"
}
}
- 等级由
RUST_LOG控制(EnvFilter 语法)。默认info,sqlx::query=warn。 observability.tracing.format: text可改为人类可读格式。- 不要在生产开
debug:Postgres / SQLite 的查询会以 INFO 级吐出。
请求日志(数据库)
每条 HTTP 代理请求都会异步写入 request_logs 表,字段比 stdout 日志更全(包含 request/response body 摘要)。通过 Admin API 检索。
| 字段 | 类型 | 含义 |
|---|---|---|
id | string | log_xxx,响应头 X-Gateway-Request-Id 返回的就是它。 |
project_id / gateway_key_id | string | 归属。 |
namespace / model / path | string | 路由信息(namespace 来自 URL /v1/{namespace}/... 段)。 |
status | string | ok / gateway_error / upstream_error。 |
http_status | int | 实际返回客户端的状态码。 |
error | string? | 错误码字符串(如 budget_exceeded、rate_limited)。 |
input_tokens / output_tokens | int | 上游或估算值。 |
cost_usd | float | 由 pricing-catalog.json 计算。 |
request_body / response_body | string | 各限 64KB,超出截断。 |
cache | string | hit / miss / refresh / bypass。 |
outcome | string | primary / fallback:N / error。 |
elapsed_ms / upstream_ms / queue_ms | int | 计时拆分。 |
保留期:Lite 模式由 storage.sqlite.log_retention_days 控制(默认 30 天),后台定期清理。Standard 模式目前不自动清理,需要在 Postgres 侧用 partition 或定时任务管理。
OTLP Tracing
observability:
tracing:
enabled: true
format: json
otlp_endpoint: http://otel-collector:4318
启用后会同时把 span 推到 OTLP HTTP 端点。span 名:
proxy.request— 顶层,涵盖整个请求。proxy.attempt— 每次上游尝试,带attempt.index、target.provider、outcome等。cache.lookup/cache.write。ratelimit.check/budget.check。
Span 上的 request_id attribute 与 X-Gateway-Request-Id、Admin API 日志条目的 id 相同,便于跨系统对齐。
安全模型
Master Key
启动时从环境变量 GATEWAY_MASTER_KEY 读入,base64 编码的 32 字节。两处用途:
- BLAKE3 keyed-hash 派生 Gateway API Key 的 hash(数据库里只存 hash)。
- HS256 签名 Admin JWT。
只要 master key 不泄漏,数据库被拷走也无法伪造 token 或反推 Gateway Key 明文。
风险面:
- 丢失 master key → 所有 Admin JWT 失效、所有 Gateway Key 无法再被验证(需重建)。
- 泄漏 master key → 攻击者拿到数据库后能伪造 Admin JWT、构造可被验证通过的 Gateway Key。
- 上游供应商凭证(
providers.<x>.credential)不进数据库,所以这里跟它没关系 —— 那部分依赖的是 YAML / 环境变量本身的保护。
强烈建议从 KMS/Vault 注入,本地不留副本,且不写进任何配置文件或镜像。
目前不支持轮换,见 部署指南 > Master Key 轮换。
Gateway API Key
形态:sk-gw-{live|test}-{32 个随机 alphanumeric 字符}。
- 生成(
security/gateway_key.rs:47):用系统 RNG 产生 32 个字符,拼前缀。 - 落库:
hash = blake3::keyed_hash(master_key, plaintext_bytes),32 字节,只存 hash + 前缀 + last4。 - 验证(
gateway_key.rs:90):接收到的明文同样 hash 后,用subtle::ConstantTimeEq常时间比较。 - 状态:
active/revoked,撤销是软删,可在 Admin API 看到。 - 来源(
origin列):admin(POST /admin/keys创建,可撤销)或config(配置文件gateway_keys[]预置,不能从 Admin 撤销,只能从 YAML 删条目再 reload)。
Key 创建后明文只在 HTTP 响应里出现一次,不会再次返回。预置 key 的明文写在 YAML 里就是磁盘留底,落库前同样会被 hash —— 不留原文,但配置文件本身要做好权限管控,或改用 secret: env://VAR_NAME 形态从环境变量注入。详见 配置参考 > gateway_keys。
Admin 鉴权
两种 principal:
Root Token
来源由 admin.root_token 决定:
-
默认
env://GATEWAY_ROOT_TOKEN,从该环境变量读明文; -
写成
env://<其他变量名>指向别的变量; -
直接写一个非
env://开头的字符串,会被当成 token 字面量(本地开发方便,生产建议走 env://); -
留空则关闭 Admin API。
-
直接放在
Authorization: Bearer <token>中。 -
常时间比较,无过期。
-
任何 Admin 端点都可用。
-
适合运维 / 引导 / 脚本场景。不要写进客户端代码或 UI。
清空该环境变量 → Admin API 全部 401。
Admin JWT
通过 /admin/auth/login 用用户名 + 密码换取。
- HS256 签名,签名密钥为 master key。
- Claims:
{ sub: user_id, username, iat, exp }。 - TTL 默认 12 小时(
SESSION_TTL_SECS)。 - 仅在
admin.password_login: true时启用/login端点。
密码用 Argon2 默认参数哈希后落库(security/admin_auth.rs)。校验失败统一返回 unauthorized,不暴露差异(防用户名枚举)。
上游凭证
通过 YAML 中 providers.<x>.credential 注入。两种形态:
env://VAR_NAME—— 启动时从该环境变量读取(推荐生产场景,凭证不进 YAML 文件,也不进 git)。- 任意其他字符串 —— 作为字面量直接用,适合本地开发或一次性试运行。
不再支持 secret://<id> 那条经过 Admin API + 数据库加密落库的路径。轮换上游 key 时直接改 env 或 YAML 然后让网关热重载 / 重启即可。
传输安全
网关进程不监听 TLS。生产环境必须放在 TLS 终端后面:
- Caddy / nginx / Envoy
- 云负载均衡(ALB / NLB / GCP LB)
- K8s Ingress(
cert-manager)
如果直接暴露 8080 端口,Gateway Key 和 Admin token 都会以明文走线。
输入校验
- 请求 body 大小:axum 默认上限(
tower-http::limit::RequestBodyLimitLayer未额外设置,框架默认 2MB / chunked 无限),建议在反代层加client_max_body_size。 model_prefix匹配前会先尝试解析 JSON 取model字段;不是 JSON 或字段不存在则当作"无 model"处理。- 配置文件 YAML 在加载时跑
AppConfig::validate,引用未知 provider 会拒绝启动 / 拒绝热重载。
日志中的敏感数据
- 请求 / 响应 body 会写入日志表(限 64KB,超出截断)。如果 prompt 中包含 PII,请按合规要求决定是否关掉 body 落库 —— 目前没有开关字段,需要修改
MAX_LOG_BODY_BYTES或在反代层做脱敏。 - 上游 API Key、Gateway Key 不会落库或出现在 stdout 日志。
- Master key 不会出现在任何日志。
已知边界
| 类别 | 现状 |
|---|---|
| 角色 / 权限 | 仅 root vs admin,无更细粒度。 |
| 审计日志 | Admin API 调用本身不落库,只代理请求落库。 |
| Key 轮换 | 没有自动轮换,需要手动撤旧建新。 |
| Master Key 轮换 | 无内置流程。 |
| CSRF | UI 是 SPA,登录后 JWT 用 Authorization(非 cookie),无 CSRF 表面。 |
| Rate-limit on /admin | Admin API 不参与 limits[],假定运维不会 DDoS 自己。 |
开发指南
环境要求
- Rust 1.85+(
rust-toolchain.toml未固定,跟workspace.package.rust-version对齐即可)。 - 测试用 Postgres + Redis 时,Docker 即可。
- 跑 mock 上游需要 Python 3.10+(
scripts/mock-openai.py)。
项目结构
crates/
├── gateway-core/ # 配置、proxy engine、provider 适配、cache、pricing、安全
├── gateway-storage/ # store trait + sqlite / postgres / redis / memory 实现
├── gateway-api/ # axum 路由、admin、auth、ratelimit、budget、reload
├── gateway-bin/ # 二进制入口
└── gateway-ui/ # 内嵌管理 UI(打包后的静态 index.html)
migrations/
├── sqlite/ # sqlx migrate 文件
└── postgres/
config/ # 示例 + 测试用 YAML
docs/ # mdBook 源(本目录)
scripts/mock-openai.py # 集成测试用的 mock 上游
pricing-catalog.json # 默认定价表(模型 → 单价,启动时通过 --pricing-catalog 指定路径)
编译与运行
# Debug 编译
cargo build
# Release 编译
cargo build --release --bin gateway
# 用纯内存后端起一个 dev 实例(不需要 DB)
cargo run --bin gateway -- --config config/test.memory.yaml
# Lite + 自带 mock 上游
python3 scripts/mock-openai.py & # 监听 :18181
cargo run --bin gateway -- --config config/test.mock.yaml
测试
# 单元 + 集成测试
cargo test --workspace
# 只跑某个 crate
cargo test -p gateway-core
# 关掉日志输出 noise
RUST_LOG=warn cargo test --workspace
集成测试中:
config/test.standard.yaml假定 httpbin.org 可达。config/test.cost.yaml/test.mock.yaml用本地mock-openai.py(端口 18181)。config/test.limits.yaml用 httpbin.org 的/anything跑通限流。
数据库迁移
# SQLite (Lite)
sqlx migrate run --source migrations/sqlite --database-url sqlite://./data/gateway.db
# Postgres (Standard)
sqlx migrate run --source migrations/postgres --database-url postgres://gateway:gatewaypass@127.0.0.1:54329/gateway
启动时 SqliteBackend::open / PostgresBackend::open 会自动跑一次 migrate,所以平时不用手动跑;手动跑的场景是新增 migration 后调试。
代码风格
cargo fmt --all提交前过一遍。cargo clippy --workspace --all-targets -- -D warnings当 CI 门槛。- 错误传播用
thiserror::Error自定义 +anyhow::Result在 binary 边界。 - HTTP handler 返回
Result<Response, ApiError>,ApiError知道如何映射到 HTTP code。 - 时间戳一律 unix ms / unix sec(看上下文),不用 chrono 类型穿过 trait。
加新的供应商
绝大多数情况下你不需要写代码 —— 如果新供应商的 API 兼容 OpenAI 协议(豆包、DeepSeek、Groq、Together、Mistral、Azure OpenAI、vLLM、Ollama、LM Studio 等都属于这类),直接在 YAML 里加一条 providers.<name>: { kind: openai, base_url: ..., credential: ... } 即可。具体写法见 配置参考 > providers。
只有当上游使用完全不同的认证协议时(既不是 OpenAI 也不是 Anthropic),才需要新建一个 auth adapter:
- 在
crates/gateway-core/src/providers/加一个<name>.rs,实现AuthInjectortrait(看openai.rs和anthropic.rs作模板)。adapter 主要做两件事:- 改写 outgoing request 的 auth header(替换客户端送来的 Authorization)。
- 注入该 provider 要求的固定 header(如 anthropic 的
anthropic-version)。
- 在
providers/mod.rs:pub mod <name>;- 在
build_auth_injector的 match 里加一个kind分支 - 在
is_known_provider_kind里加同一个kind—— 让启动期validate认识它
- 在
pricing-catalog.json加该供应商的模型 → 单价(如果你想跟踪成本)。 - 写测试(参考
crates/gateway-core/src/providers/openai.rs末尾的单元测试)。 - 配置文件里用
providers.<name>: { kind: <kind>, ... }引用。
至于 token usage 抽取(用于成本核算和 TPM 限流):目前在 crates/gateway-api/src/tokens.rs::extract_token_usage 集中处理,假设上游响应体里有标准的 usage 字段(OpenAI / Anthropic 都满足)。完全不同形态的响应需要在那里加一条解析分支。
加新的 Admin 端点
- 在
crates/gateway-api/src/routes/admin/加一个<resource>.rs,handler 签名:#![allow(unused)] fn main() { pub async fn handler( State(state): State<AppState>, principal: AdminPrincipal, /* extractors */ ) -> Result<Json<Response>, ApiError> } - 在
routes/admin/mod.rs暴露,然后在server.rs::build_router的adminRouter 里.route(...)。 - 给 store trait 加方法(若需要新数据);分别在 SQLite / Postgres / Memory 实现。
- 写集成测试(参考
crates/gateway-api/tests/模板)。 - 更新 Admin API 文档。
配置字段加 / 改
crates/gateway-core/src/config.rs加字段,记得加#[serde(default)]和默认值函数,保持向后兼容。- 在
AppConfig::validate里加校验(若需要)。 - 跑
cargo test -p gateway-core config验证序列化/反序列化。 - 更新 配置参考。
- 改动若涉及行为,加到 架构概览 中对应小节。
文档
文档源在 docs/src/,mdBook 项目根在 docs/。
cargo install mdbook
cd docs
mdbook serve --open # 本地预览,改文件自动刷新
mdbook build # 输出到 docs/book/
每次 PR 改了代码行为,记得同步改文档。GitHub Actions 会在 push 到 main 时自动构建并发布到 GitHub Pages,见 .github/workflows/docs.yml。