分层
代码分为三层,每一层都是下一层再加上一类决策:sdk
gproxy-sdk 是可嵌入的句柄。它用默认实现装配出引擎(gproxy-core),执行推进 settings.config_revision 的配置写入,把完成的登录转换为一行凭证,把模型名解析为执行计划,并让同一部署中的所有实例保持在同一个 revision 上。sdk 不含 HTTP server,也没有用户的概念。详见嵌入 SDK。
app
gproxy-app 是产品层,负责判断调用方是谁、能访问什么。用户、网关 API key、组织、团队、权限、限流、OAuth issuer 和审计都属于这一层,写入这些数据的操作也在这里。app 不含 router、runtime 和 CLI,因此同一份代码既能运行在原生 axum server 后面,也能运行在 Cloudflare Worker 里。
host
宿主把某种传输方式转换为对 app 的类型化调用,再把结果转换回字节。目前有三个宿主:axum HTTP server、Cloudflare Workers 宿主,以及 Tauri 桌面与 Android 应用。 每一层把收窄后的结果交给下一层,下层不再重新判断。引擎拿到的是已解析的目标和允许使用的凭证集合,看不到调用方本身,也无法自行推断调用方的身份。workspace 中的 crate
所有 crate 都位于crates/ 下,版本号和 edition 由 workspace 统一指定(edition = "2024")。标为 MIT 的 crate 可以单独使用,其余为 AGPL-3.0-or-later。
gproxy-host-tauri 是 workspace 成员,但不在默认成员中,因此不带 --workspace 的 cargo check 和 cargo test 会跳过它。构建它需要 webkit2gtk、gtk 和 libsoup 的开发头文件,见从源码构建。
依赖方向
依赖只能向下:gproxy-client-sdk 供调用 GPROXY 实例的程序使用,只依赖 gproxy-protocol、gproxy-client 和 gproxy-file,不依赖引擎。它的进程内后端 gproxy_sdk::ClientBackend 放在 gproxy-sdk 的 client-sdk feature 后面,因此依赖方向是 AGPL 的引擎依赖 MIT 的客户端,而不是反过来。
引擎不会拥有监听器、router 或 UI。如果某项修改需要 gproxy-core 知道 HTTP 路径、用户或权限,这项修改应当放到更高的层。
各类问题由谁回答
宿主不能自行回答前五个问题。否则同一个判断只会在 HTTP 上生效而不会在 IPC 上生效(或者反过来),两个入口的行为就会不一致。
请求的处理流程
经由原生 server 的数据面请求按以下步骤处理:gproxy-host-axum把路径匹配到某个操作和方言,把请求解码为DataPlaneRequest。对于模型名写在路径里的方言(Gemini),宿主在这里解析出模型名。gproxy-app对请求做认证,得到一个Caller。App::call执行准入,得到Admitted,其中包含 scope、用量归属、预算所有者链、允许的供应商集合、允许的凭证集合和会话身份。限流计数也在这一步扣除。- app 用上述结果和它解析出的模型名驱动 sdk 的调用 builder。引擎看不到
Caller。 - sdk 把模型名解析为一个
Plan(见下文),并依次尝试其中的目标。 - 对每个目标,引擎在允许集合内选出一把凭证,通过渠道准备请求;上游使用不同方言时先做转换,再对实际要发出的请求应用供应商的改写规则(因此规则看到的总是上游格式),然后经
gproxy-client发出。 - 引擎对应答分类:换同一供应商的另一把凭证重试;把失败交回 sdk(sdk 可能换下一个供应商);或者经结算漏斗返回响应。
- 宿主写出响应。对于流式响应,宿主会让
Admitted一直存活到最后一个字节写出为止。并发许可统计的是在途请求,而call()返回之后流仍在传输。
tick()(见配置写入与重载)。
模型解析与执行计划
解析发生在 sdk 中、进入引擎之前,因此引擎只会看到已解析的目标。模型名按第一条匹配的规则解析:
公开模型名按精确匹配,并且优先于前缀形式,因此运维者可以把字面量
openai/gpt-5 作为一条路由公开出来。前缀始终是供应商名,而不是渠道 id:codex 渠道上名为 openai 的供应商,用 openai/… 访问。
随后按渠道(调用方指定了渠道时)、允许的供应商 id 和允许的凭证 id 收窄候选。产品层通过凭证 id 过滤,防止一个组织的凭证被另一个组织的请求使用。允许集合为空表示什么都不允许,解析结果为 no target 错误。
在每个候选内部,已禁用、已退役和已失效(dead)的凭证会被移除。被临时封禁的凭证只有在该供应商还有未封禁的凭证时才会移除;如果某个供应商的凭证全部处于临时封禁状态,它仍保留在计划中,但健康度排名较低,排在所有健康的供应商之后。也就是说,被限流的供应商会放到最后尝试,而不是直接视为不可用。
候选按 (tier, 健康度, 权重倒序, 稳定 id) 排序。tier 是硬性优先级,后面的 tier 不会与前面的 tier 一起做均衡。只有排在最前的一段(与第一个候选 tier 和健康度相同的候选)按路由的策略做均衡。轮转计数器保存在进程内,丢失时只会让轮转从第一个候选重新开始。
解析结果是一个 Plan:解析后的模型名、整个计划共享的 max_attempts,以及一个非空的 Target 列表。每个 Target 对应一个供应商、一个上游模型,以及调用方可以使用的该供应商的凭证。
失败转移
失败转移分两级。 在单个供应商内部,由引擎在该供应商允许使用的凭证之间切换:选一把凭证发出请求,对应答分类;如果换一把凭证可能成功,就选下一把。 在供应商之间,由 sdk 切换到计划中的下一个目标,但只针对另一个供应商有可能处理成功的失败:
第二组是调用方自己的限制,第三组说明请求或配置本身有问题、换到哪里都会失败,第四组说明当前实例出了故障。这些情况换供应商重试只会多花钱。
尝试预算由整个计划共享。每个目标最多分到与其凭证数相同的尝试次数,且不超过剩余预算;无论是否用完,分到的次数都会被扣除。因此一个计划发出的上游调用次数不会超过它的
max_attempts。没有目标可换时,最后一个应答原样返回:最后一个供应商返回 429,调用方收到的就是 429。
配置写入与重载
所有配置写入都经过 sdk 中的同一个原语:reload_credentials 路径。凭证的其他属性在装配时已固定到执行快照中,修改后需要完整重载。
多实例同步
实例通过两种方式得知其他实例的写入:
通知不携带状态,只是提示实例重新检查。轮询结果才是权威,不会漏掉任何写入,因此丢失一条通知最多让变更晚一个轮询周期生效。重载是串行且单调的:旧的 revision 不会覆盖新的,重载失败时继续使用上一份快照。
使用
SyncMode::Background(原生默认)时,句柄自己运行订阅循环和轮询循环。wasm 上两次请求之间没有常驻进程,同步只能手动进行:edge 宿主在每个请求开始时调用 tick(),先应用待处理的通知,再轮询一次 revision。
多实例部署需要实例之间共享的 cache:原生环境用 Redis,edge 上用基于 D1 或 libSQL 的 StoreCache。默认的 MemoryCache 只在进程内有效。
结算与计量
所有到达上游的路径,最后都经过引擎中的同一个结算漏斗:为本次交换计价,扣减所有适用的预算窗口,再把用量报告交给观察者(observer)。每个请求只结算一次。不存在绕过结算的快速路径,因为那样的路径就是未计量的流量。 细节如下:- 预算在第一次尝试之前检查,在上游应答之后按计价结果结算。由于费用只有事后才知道,在途请求和并发请求可能使预算超出上限。
- 结算按请求 id 幂等。结算失败只会丢失记账,不影响已经交付给调用方的响应。
- 没有匹配价格规则的模型按 0 结算,其用量带有
unpriced = true维度,方便运维者找出来。 - 用量按每次实际的上游调用记录。
- 请求捕获等可选工作,在请求执行之前就决定是否进行。捕获关闭时不会复制 body,也不会为它分配内存。(v3 的做法是先完成这些工作,再由 sink 决定是否丢弃。)
StoreObserver 写入数据库,宿主可以替换它。
构建 WebAssembly 版本
gproxy-app 和 axum router 都能为 wasm32-unknown-unknown 构建,所以 gproxy-host-edge 直接挂载同一个 Router,不维护自己的路由表。axum 的 server 部分(http1、http2、tokio)是 Cargo feature,Router 本身与目标平台无关,因此 wasm 构建去掉的只是套接字处理,而不是路由。
难点在于 Send。wasm 上的引擎有意设计为 !Send:JS 传输句柄属于创建它的 isolate,gproxy-client 的 ClientBounds 在原生环境下是 Send + Sync,在 wasm 上为空。而 axum 要求 router 的 state 满足 Send + Sync、handler 的 future 满足 Send。这里用两处桥接解决,二者都依赖 Worker isolate 是单线程这一前提:
gproxy_app::App把 sdk 句柄放在send_wrapper::SendWrapper中。gproxy_host_axum::send包装每个 handler 的函数体(以及每个流式响应的 body)。在原生环境下它是恒等函数;在 wasm 上它是SendWrapper,如果在其他线程上被 poll 会 panic。
send 的 handler 在 wasm 目标上会编译失败,错误信息中会指出是哪个 handler。包装之所以放在每个 handler 上,而不是在 router 上加 cfg,就是为了让无法为 edge 编译的路由不会在 edge 上悄悄缺失。
wasm 上默认的 cache 是 StoreCache,同步始终是手动的(见上文)。仅限原生的功能(PostgreSQL、MySQL、本地文件存储、代理、TLS 模拟)在 wasm 上不可用,edge 上的数据库选项是 libsql 和 D1 binding。Worker 的构建与部署见从源码构建。
设计规则
修改代码时,请保持以下性质:- 引擎不依赖 server 框架、router 或 UI。
- 所有到达上游的请求都经过结算漏斗。
- 转换在厂商方言之间成对进行,没有中间表示。转换器只读取源类型中声明过的字段,从不读写未知字段袋。
- 一次配置写入与其 revision 自增在同一个事务中完成。
- 快照发布是单调的,重载不会回退。
- 装配不会因为某一行数据有问题而失败,这一行会被跳过、计数并记录日志。
- edge 宿主没有自己的路由表。
- 前端类型由 Rust 生成(见 TypeScript 类型导出),不手写。