分層
程式碼分為三層,每一層都是下一層再加上一類決策: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 型別匯出),不手寫。