跳轉到主要內容
本文說明 GPROXY v4 原始碼樹的組織方式,以及一個請求在其中的處理流程。修改宿主以下的任何程式碼之前,或者想確認某項行為應該放在哪個 crate 時,請先閱讀本文。 v4 是對 v3 的重寫。閘道的功能沒有變,程式碼的劃分方式不同。本文末尾的大部分規則,都是為了讓這種劃分不被逐漸破壞。

分層

程式碼分為三層,每一層都是下一層再加上一類決策:

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 的資料面請求按以下步驟處理:
  1. gproxy-host-axum 把路徑匹配到某個操作和方言,把請求解碼為 DataPlaneRequest。對於模型名寫在路徑裡的方言(Gemini),宿主在這裡解析出模型名。
  2. gproxy-app 對請求做認證,得到一個 Caller。
  3. App::call 執行准入,得到 Admitted,其中包含 scope、用量歸屬、預算所有者鏈、允許的供應商集合、允許的憑證集合和會話身份。限流計數也在這一步扣除。
  4. app 用上述結果和它解析出的模型名驅動 sdk 的呼叫 builder。引擎看不到 Caller。
  5. sdk 把模型名解析為一個 Plan(見下文),並依次嘗試其中的目標。
  6. 對每個目標,引擎在允許集合內選出一把憑證,透過渠道準備請求;上游使用不同方言時先做轉換,再對實際要送出的請求套用供應商的改寫規則(因此規則看到的一律是上游格式),然後經 gproxy-client 發出。
  7. 引擎對應答分類:換同一供應商的另一把憑證重試;把失敗交回 sdk(sdk 可能換下一個供應商);或者經結算漏斗返回響應。
  8. 宿主寫出響應。對於流式響應,宿主會讓 Admitted 一直存活到最後一個位元組寫出為止。併發許可統計的是在途請求,而 call() 返回之後流仍在傳輸。
edge 宿主執行的是同一個 router,所以第 1 到第 8 步完全相同,只是在第 1 步之前多呼叫一次 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 中的同一個原語:
資料行和 revision 自增在同一個事務中寫入。如果行寫入了而 revision 沒有自增,其他實例永遠不會重新載入,這項修改在它們那裡就看不到;如果 revision 自增了而行沒有寫入,所有實例都會白白重新載入一次。 寫入方先重新載入自己的快照,再發布通知,這樣它就不會宣告一個自己還無法提供的 revision。發布是盡力而為的:cache 拒絕訊息時,其他實例會在下一次輪詢時得知變更,寫入方只記錄一條警告。 寫入時宣告的 scope 決定隨後執行哪種重新載入。只修改憑證金鑰、過期時間和生命週期狀態的寫入,可以使用憑證狀態 scope,走開銷更小的 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 型別匯出),不手寫。