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 形态发请求。

阅读建议

第一次接触建议按顺序读:

  1. 快速开始 — 5 分钟跑起来。
  2. 代理 API — 客户端怎么调网关。
  3. 配置参考 — 所有 YAML 字段。
  4. 部署指南 — Lite vs Standard,生产注意事项。

需要排查问题或对接监控时,看 可观测性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"}]
  }'

网关会:

  1. 用 Argon2/BLAKE3 哈希验证 Gateway Key。
  2. 检查限流、预算。
  3. OPENAI_API_KEY 转发到 https://api.openai.com/v1/chat/completions
  4. 计算成本、落库一条请求日志,更新指标。
  5. 把上游响应原样返回。

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_KEY32 字节 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_LOGtracing EnvFilter 表达式,默认 info,sqlx::query=warn
OPENAI_API_KEY / ANTHROPIC_API_KEY / ...视配置任何被 credential: env://VAR 引用的变量。

server

字段类型默认说明
bindstring0.0.0.0:8080监听地址,标准 host:port
request_timeout_msu64600000单次请求总超时(毫秒),含上游往返。
default_project_idstringdefault启动时自动 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_mb10240软上限,用于日志清理触发参考。
sqlite.log_retention_days30请求日志保留天数。
cache.l1_memory_mb256L1 内存缓存容量。
cache.l2_max_size_mb1024L2(SQLite)缓存容量。

注意:GATEWAY_WORKERS>1lite 不兼容,网关会拒绝启动。

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_connections32连接池大小。
redis.url(必填)Redis 连接串。
cache.l1_memory_mb256L1 内存缓存。
cache.l2_max_size_mb1024L2(Redis)缓存。

profile: memory

全内存,无持久化。重启即失,只用于测试。

storage:
  profile: memory
  cache:
    l1_memory_mb: 64
    l2_max_size_mb: 256

admin

字段类型默认说明
root_tokenstringenv://GATEWAY_ROOT_TOKENroot 权限的 Admin token。三种取值:""(空)→ 禁用 Admin API;env://VAR_NAME → 从环境变量读(未设或为空也视作禁用);其他字符串 → 直接当明文 token 用(与 gateway_keys[].secret 写明文同样的安全权衡,适合本地开发)。
password_loginboolfalse是否启用用户名/密码登录(配合 /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
字段类型必填说明
idstring行 id,跨重载稳定。gateway_keys[] 内必须唯一。
namestring展示名,Admin 端列表里看到的就是它。
project_idstring落到哪个 project。省略则用 server.default_project_id
secretstring明文 sk-gw-{live,test}-...,或 env://VAR_NAME。明文形态会用 derive_hash 哈希后落库,不会保留原文;env 形态延迟到 sync 时解析(变量缺失或为空会被打印到日志并跳过这一轮)。
scopesstring[]与 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.providerfallbacks[].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
字段必填说明
kindAuth adapter 类型。未设置时,落到 map 里的 key 名(只有那些 key 名等于内置 adapter 名的配法才能省略)。已支持的取值见下。
base_url上游基础 URL,客户端路径会拼在后面。
credential上游 API key 来源。env://VAR_NAME 在启动期读环境变量;任何其他字符串作为字面量直接当 token(YAML 里写明文,适合本地开发,生产建议走 env://)。
headers透传给上游的额外固定 header。

支持的 kind

适用上游Auth 行为
openaiOpenAI 官方,以及任何沿用 OpenAI 协议的第三方(豆包、DeepSeek、Together、Groq、Mistral、Azure OpenAI、vLLM、Ollama、LM Studio…)Authorization: Bearer <key> + api-key: <key> 双 header
anthropicAnthropicx-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

每条路由把"哪些请求"绑定到"上游身份和代理策略(缓存、重试、回退)"。按数组顺序匹配,第一条同时满足以下条件的命中:

  1. URL 中的 /v1/{namespace}/... 段等于 match.namespace(必填,启动时校验);
  2. 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_prefixNone请求体 model 字段的字符串前缀。例:gpt- 匹配 gpt-4o-minigpt-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

字段默认说明
enabledfalse是否对该路由启用响应缓存。
ttl3600缓存秒数。
allow_nondeterministicfalsetrue 时,即使请求不是确定性采样(temperature != 0top_p < 0.999)也写入/读取缓存。与请求头 X-Gateway-Cache-Force 按"或"关系合并 —— 任一为真即放行。

缓存 key 来自请求体 + 路径的 blake3 摘要。流式响应也会被缓存(replay 时按原 chunk 边界发回),前提是请求体确定性(temperature == 0top_p >= 0.999)且大小 ≤ 20 MB。要绕开确定性检查,可在路由上设置 allow_nondeterministic: true,或在请求头加 X-Gateway-Cache-Force: 1

retry

字段默认说明
max_attempts3同一目标上的最大尝试次数(含首次)。
initial_backoff_ms500初始退避,按指数增长。

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

typeid 语义
key匹配 Gateway API Key 的 ID;"*" 表示所有 Key。
project匹配项目 ID;"*" 表示所有项目。
global全局,无视 id
metadata预留,目前未启用。

度量字段(都可省略)

字段含义
rpmRequests per minute 上限。
tpmTokens 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_idgateway_key_id 二选一;不填则不会累计任何流量。
target.gateway_key_id限定到具体 Key。
perioddaily / weekly / monthly,UTC 边界。未知值按 monthly 处理。
amount_usd周期总预算,单位美元。
thresholds[].at0–1 的百分比阈值。
thresholds[].actionnotify(发 webhook,默认幂等) / block(拒绝请求)。
thresholds[].webhooknotify 时使用,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
字段默认说明
metricstrue是否暴露 Prometheus /metrics 端点。
tracing.enabledtrue是否开启 tracing。
tracing.formatjsonjsontext,影响 stdout 日志格式。
tracing.otlp_endpointnull可选 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/completionsPOST https://api.openai.com/v1/chat/completions
POST /v1/anthropic/v1/messagesPOST https://api.anthropic.com/v1/messages
GET /v1/openai/v1/modelsGET https://api.openai.com/v1/models

认证

请求头携带 Gateway API Key:

Authorization: Bearer sk-gw-live-xxxxxxxxxxxx

网关会:

  1. 用 BLAKE3-keyed-hash 算出 hash,常时间对比数据库里的 hash。
  2. 检查 Key 状态(active / revoked)和 expires_at
  3. 异步更新 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.providerproviders 表里的 key —— 是两个独立概念,允许同一个 namespace 在后端切换到不同 provider。详见 配置参考 > routes。命中后:

  1. 检查 cache.enabled,且请求体是确定性的(temperature == 0top_p >= 0.999,或路由配了 cache.allow_nondeterministic: true,或带 X-Gateway-Cache-Force 头),则查缓存,命中则直接返回,响应头加 X-Gateway-Cache-Status: hit
  2. 否则按 primary 转发,失败重试至 retry.max_attempts 次。
  3. 仍失败且 trigger 命中 → 切到下一个 fallbacks[]
  4. 成功响应若满足缓存条件(≤ 20 MB、状态 2xx),写回 KV;流式响应也会缓存,replay 时保留 chunk 边界。
  5. 写日志,返回响应。

没有匹配的路由时,网关直接返回 404(错误码 not_found),不会再把 namespace 当作 providers 表的 key 做兜底转发。想暴露 /v1/<name>/...,必须在 routes[] 里写一条对应的条目(最简写法 - primary: { provider: <name> })。

响应头

网关附加的特殊头(其他都透传):

Header含义
X-Gateway-Request-Id本次请求的内部 ID,与 /admin/logs 中的 id 对应。
X-Gateway-Cache-Statushit / 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
404URL 中的 {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 /metricsPrometheus 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 取值为 rootuser

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显示用,任意字符串。
envlivetest,只影响 key 的前缀(sk-gw-live- / sk-gw-test-),目前不参与认证、路由、限流、预算等任何逻辑。仅作为肉眼可见的标签,用于区分生产与测试 key(借鉴 Stripe 的命名约定)。
project_id默认 server.default_project_id
scopes默认 ["proxy"]。目前 scope 不参与执行,占位。
expires_atunix 秒。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 / revokedorigin 取值:admin(由本 API 创建)或 config(由 gateway_keys[] 预置,见 配置参考 > gateway_keys)。

DELETE /admin/keys/:id

撤销 Key(软删,status 变为 revoked)。originconfig 的 key 不能从这里撤销 —— 接口会返回 400 bad_request,需要在 YAML 里把这条记录删掉再 reload。

权限:Any。

响应:

{ "id": "key_...", "status": "revoked" }

请求日志 /admin/logs

GET /admin/logs

分页检索请求日志。

权限:Any。

查询参数(都可选):

参数类型说明
project_idstring按项目过滤。
gateway_key_idstring按 Key 过滤。
namespacestring按 URL namespace 过滤(对应 /v1/{namespace}/... 段)。
modelstring按模型名过滤(精确)。
statusstringok / gateway_error / upstream_error
from / tointunix 秒,时间范围。
limitint默认 50,上限 200。
cursorstring上一页响应里的 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_bynamespace / 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-storageMetadataStore / LogStore / KvStore / CounterStore 抽象,以及 SQLite、Postgres、Redis、内存四套实现。
gateway-apiaxum 路由、Admin API、Gateway Key 认证、限流、预算、配置热重载。
gateway-bin进程入口:加载配置、初始化 stores、装配 ProxyEngine、启动 HTTP 服务。
gateway-ui内置的最简 Admin UI(include_str! 注入)。

请求生命周期

一次代理请求(/v1/{namespace}/...)经过的环节,按顺序:

  1. 认证 (auth.rs) — 从 Authorization 头取 Gateway Key,BLAKE3 keyed-hash 后查表,常时间对比,核对 statusexpires_at
  2. 读 body (routes/proxy.rs) — 整体读入内存(流式响应除外),计算 blake3 摘要,提取 model 字段。
  3. 预算检查 (budget.rs) — 命中 action: block 阈值则直接 402。
  4. 限流 (ratelimit.rs) — 按 limits[] 配置依次检查 RPM / TPM / 并发。
  5. 路由匹配 — 按数组顺序找第一条满足 match.namespace == URL段(未设置时默认 primary.provider)且 match.model_prefix(若设置)命中的路由。namespace 是对外暴露的 URL 段,primary.provider 才是 providers 表的 key,两者解耦。没有匹配则用 ProviderChain::primary_only,把 URL 段直接当作 provider 名(无缓存、无 fallback、默认重试)。
  6. 缓存查找 — 若 cache.enabled,以 (provider, path, body_blake3) 为 key 查 L1(内存)→ L2(SQLite/Redis)。命中则直接返回,X-Cache: HIT
  7. 构造 forward request — 改写凭证、Host,追加 providers.headers
  8. 执行链 (proxy/executor.rs) — 对 primary 尝试 retry.max_attempts 次(指数退避);失败若命中 fallback.trigger 则切换到下一个 target,直到耗尽。
  9. 写缓存 + 落日志 — 非流式且可缓存的响应写回缓存;不论成败都通过 LogStore 异步落库。
  10. 更新预算与指标 — 用上游返回的 token usage 算成本,累加到 BudgetManager,bump Prometheus counter/histogram。
  11. 返回响应 — 透传 status / headers / body,附加 X-Gateway-Request-IdX-CacheX-Provider

存储抽象

StoreBundle 把四种 store 组合在一起,供 API 层使用:

StoreLite (SQLite + Memory)Standard (Postgres + Redis)Memory (测试)
MetadataStoreSQLitePostgres进程内
LogStoreSQLite(异步批量写)Postgres(异步批量写)环形缓冲 10k
KvStore (cache)内存 L1 + SQLite L2内存 L1 + Redis L2内存
CounterStore内存Redis内存

Lite 模式下 CounterStore 是进程内 —— 这就是为什么 GATEWAY_WORKERS>1lite 不兼容:多进程之间无法共享 RPM/TPM/并发计数。

配置热重载

gateway-api/reload.rsnotify 监听配置文件变化:

  • 文件变更触发再次 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_responseBody::from_stream 按原 chunk 边界 replay,所以 SSE 客户端看到的事件流跟首次一致。缓存写入默认需要请求体确定性(temperature == 0top_p >= 0.999),否则一律 bypass;路由可设 cache.allow_nondeterministic: true 跳过此检查(等价于全局打开 X-Gateway-Cache-Force)。

重试 / 回退

proxy/executor.rsprimary + 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_NAMEstd::env::var,变量未设直接报启动失败。
  • 任何其他字符串 — 当作字面量 token 用,不做任何转换。

凭证存活到下次进程重启;轮换上游 key 需要改 YAML 或环境变量后重启 / 重载 provider。

关键非目标

  • 不做 prompt 翻译/改写 —— 网关只搬运字节流。
  • 不做模型路由(按 prompt 内容选模型) —— 只按路径 + model_prefix 静态路由。
  • 不实现 SDK 抽象 —— 客户端继续按上游 SDK 写。

部署指南

形态选择

维度LiteStandard
元数据 / 日志SQLitePostgres
计数器 (RPM/TPM/并发)进程内内存Redis
L2 缓存SQLiteRedis
副本数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_KEYbase64 编码 32 字节。Gateway Key 的 BLAKE3 keyed-hash 和 Admin JWT 都靠它。生成: openssl rand -base64 32
GATEWAY_ROOT_TOKENAdmin 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_KEYGATEWAY_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.rsnotify 监听文件 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 必须:

  1. 停服,导出 DB。
  2. 用新 key 启动空库,重新创建 admin 用户和 gateway key(已签发的 Admin JWT 也会失效)。
  3. 把日志库恢复(日志不加密)。

因此生产场景强烈建议在密钥管理系统(AWS KMS / GCP Secret Manager / Vault)里存 master key,并把它视为同等级别的根密钥。

备份

数据形态备份方式
Lite SQLite./data/gateway.dbsqlite3 .backup 或冷拷贝(停服)
Standard Postgresvolumepg_dump
Standard Redisvolume(带 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_totalcountertier={l1|l2}KV 缓存命中次数(底层 KvStore)。
gateway_cache_miss_totalcounter-KV 缓存未命中。
gateway_cache_write_totalcounter-KV 缓存写入。
gateway_cache_response_hit_totalcounter-路由级响应缓存命中(用户视角)。
gateway_cache_response_write_totalcounter-路由级响应缓存写入。

限流与预算

指标类型Labels含义
gateway_ratelimit_hit_totalcounterkind={rpm|tpm|concurrency}触发限流的次数。
gateway_budget_pcthistogrambudget=<name>每次请求后预算使用率(0–1)。
gateway_budget_threshold_totalcounterbudget=<name>, action={alert|block}阈值跨越次数。

日志写入

指标类型Labels含义
gateway_log_write_totalcounter-日志条目成功落库。
gateway_log_write_error_totalcounter-日志落库失败。
gateway_log_drop_totalcounterreason=full异步队列满,日志被丢弃。

配置热重载

指标类型Labels含义
gateway_config_reload_totalcounter-配置热重载成功次数。
gateway_config_reload_error_totalcounter-配置热重载失败次数。持续 > 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 检索。

字段类型含义
idstringlog_xxx,响应头 X-Gateway-Request-Id 返回的就是它。
project_id / gateway_key_idstring归属。
namespace / model / pathstring路由信息(namespace 来自 URL /v1/{namespace}/... 段)。
statusstringok / gateway_error / upstream_error
http_statusint实际返回客户端的状态码。
errorstring?错误码字符串(如 budget_exceededrate_limited)。
input_tokens / output_tokensint上游或估算值。
cost_usdfloatpricing-catalog.json 计算。
request_body / response_bodystring各限 64KB,超出截断。
cachestringhit / miss / refresh / bypass
outcomestringprimary / fallback:N / error
elapsed_ms / upstream_ms / queue_msint计时拆分。

保留期: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.indextarget.provideroutcome 等。
    • 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 字节。两处用途:

  1. BLAKE3 keyed-hash 派生 Gateway API Key 的 hash(数据库里只存 hash)。
  2. 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 轮换无内置流程。
CSRFUI 是 SPA,登录后 JWT 用 Authorization(非 cookie),无 CSRF 表面。
Rate-limit on /adminAdmin 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:

  1. crates/gateway-core/src/providers/ 加一个 <name>.rs,实现 AuthInjector trait(看 openai.rsanthropic.rs 作模板)。adapter 主要做两件事:
    • 改写 outgoing request 的 auth header(替换客户端送来的 Authorization)。
    • 注入该 provider 要求的固定 header(如 anthropic 的 anthropic-version)。
  2. providers/mod.rs:
    • pub mod <name>;
    • build_auth_injector 的 match 里加一个 kind 分支
    • is_known_provider_kind 里加同一个 kind —— 让启动期 validate 认识它
  3. pricing-catalog.json 加该供应商的模型 → 单价(如果你想跟踪成本)。
  4. 写测试(参考 crates/gateway-core/src/providers/openai.rs 末尾的单元测试)。
  5. 配置文件里用 providers.<name>: { kind: <kind>, ... } 引用。

至于 token usage 抽取(用于成本核算和 TPM 限流):目前在 crates/gateway-api/src/tokens.rs::extract_token_usage 集中处理,假设上游响应体里有标准的 usage 字段(OpenAI / Anthropic 都满足)。完全不同形态的响应需要在那里加一条解析分支。

加新的 Admin 端点

  1. 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>
    }
  2. routes/admin/mod.rs 暴露,然后在 server.rs::build_routeradmin Router 里 .route(...)
  3. 给 store trait 加方法(若需要新数据);分别在 SQLite / Postgres / Memory 实现。
  4. 写集成测试(参考 crates/gateway-api/tests/ 模板)。
  5. 更新 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