Layers
The code is arranged in three layers. Each one is the layer below plus one class of decisions:sdk
gproxy-sdk is the embeddable handle. It assembles the engine (gproxy-core) from default
implementations, performs the configuration writes that advance
settings.config_revision, turns a completed login into a credential row,
resolves a model name to an execution plan, and keeps every instance of a
deployment on the same revision. It has no HTTP server and no notion of
users. See Embedding the SDK.
app
gproxy-app is the product layer: who is calling and what they may reach. It owns users,
gateway API keys, organizations, teams, permissions, rate limits, the OAuth
issuer and audit, together with the operations that write them. It has no
router, no runtime and no CLI, so the same code runs behind the native axum
server and inside a Cloudflare Worker.
host
A host turns a transport into typed calls on the app and turns the results back into bytes. There are three: the axum HTTP server, the Cloudflare Workers host, and the Tauri desktop and Android application. Each layer passes a narrowed answer down and the layers below do not revisit it. The engine receives a resolved target and a permitted credential set; it never sees the caller and cannot re-derive who they are.Workspace crates
All crates live undercrates/. Version and edition come from the workspace
(edition = "2024"). Crates marked MIT are usable on their own; the rest are
AGPL-3.0-or-later.
gproxy-host-tauri is a workspace member but not a default member, so
cargo check and cargo test without --workspace skip it. Building it needs
the webkit2gtk, gtk and libsoup development headers. See
Building from source.
Dependency direction
Dependencies point downward only:gproxy-client-sdk is used by programs that call a GPROXY instance. It
depends only on gproxy-protocol, gproxy-client and gproxy-file, not on
the engine. Its in-process backend, gproxy_sdk::ClientBackend, is in
gproxy-sdk behind the client-sdk feature, so the dependency points from
the AGPL engine to the MIT client and not the other way.
The engine never gains a listener, a router or a UI. A change that needs
gproxy-core to know about HTTP paths, users or permissions belongs in a
higher layer.
Who answers which question
Hosts must not answer any of the first five themselves. A host that did would
apply its answer over HTTP and not over IPC (or the other way round), and the
two front doors would disagree.
The life of a request
A data-plane request through the native server goes like this:gproxy-host-axummatches the path to an operation and dialect, decodes the request into aDataPlaneRequest, and for dialects that carry the model in the path (Gemini) parses it out.gproxy-appauthenticates the request and produces aCaller.App::callruns admission. The result,Admitted, carries the scope, usage attribution, budget owner chain, allowed provider set, allowed credential set and session identity. Rate-limit charges are taken here.- The app drives the sdk’s call builder with exactly those values and the
model name it parsed. The engine never sees the
Caller. - The sdk resolves the model name to a
Plan(see below) and walks its targets. - For each target, the engine selects a credential inside the permitted set,
prepares the request through the channel, converts it to the upstream’s
dialect if that differs from the client’s, applies the provider’s rewrite
rules to the request that will actually be sent (so a rule always sees the
upstream format), and dispatches it through
gproxy-client. - The engine classifies the answer. It either retries on another credential of the same provider, returns a failure to the sdk (which may move to the next provider), or returns the response through the settlement funnel.
- The host writes the response. For a streamed response it keeps
Admittedalive until the last byte is written, because a concurrency permit counts requests in flight and the stream is still in flight aftercall()returns.
tick() before step 1 (see
Configuration writes and reload).
Model resolution and the plan
Resolution happens in the sdk, before the engine, so the engine only ever sees resolved targets. A model name is resolved by the first rule that matches:
Exposed names are matched exactly and before the prefix form, so an operator
can expose the literal name
openai/gpt-5 as a route. The prefix is always a
provider name, never a channel id: a provider named openai on the codex
channel is reached as openai/….
The candidates are then narrowed by channel (when the caller pinned one), by
the allowed provider ids and by the allowed credential ids. The credential
filter is how the app keeps one organization’s credentials out of another’s
requests. An empty allowed set means nothing is allowed and resolves to a
no-target error.
Within each candidate, disabled, retired and dead credentials are removed.
Blocked credentials are removed only while the provider still has an unblocked
one; a provider whose credentials are all temporarily blocked stays in the plan
with a lower health rank, behind every healthy provider. A rate-limited
provider is therefore tried last instead of being treated as gone.
Candidates are ordered by (tier, health, descending weight, stable id). Tier
is a hard preference: a later tier is never balanced against an earlier one.
Only the leading run (the candidates that share the first one’s tier and
health) is balanced, using the route’s strategy. Rotation counters are
process-local; losing them only restarts rotation at the first candidate.
The result is a Plan: the resolved model name, a shared max_attempts, and a
non-empty list of Targets, each one provider, one upstream model and the
credentials of that provider the caller may spend.
Failover
There are two levels of failover. Within a provider, the engine moves between the provider’s permitted credentials: it selects one, sends the request, classifies the answer and, if the failure is one another credential could fix, selects the next. Across providers, the sdk moves to the next target in the plan, but only for failures another provider could plausibly serve:
The second group is the caller’s own limit, the third is a request or
configuration that would fail everywhere, and the fourth means this instance is
broken. Retrying any of them on another provider only costs more.
The attempt budget is shared by the whole plan. Each target is granted at most
as many attempts as it has credentials and never more than what remains, and
the grant is subtracted whether or not it was used. A plan therefore never
makes more upstream calls than its
max_attempts. When no target is left, the
last answer is returned as it is: a 429 from the final provider reaches the
caller as a 429.
Configuration writes and reload
Every configuration write goes through one primitive in the sdk:reload_credentials path.
Everything else about a credential is fixed into the execution snapshot at
assembly and needs a full reload.
Multi-instance sync
Instances learn about each other’s writes in two ways:
A notification carries no state; it only tells the instance to check again.
The poll is the authority and cannot miss a write, so a lost notification costs
at most one poll interval. Reloads are serialized and monotonic: an older
revision never replaces a newer one, and a failed reload leaves the previous
snapshot serving.
With
SyncMode::Background (the native default) the handle runs its own
subscription and poll loops. On wasm there is no process between requests, so
synchronization is manual: the edge host calls tick() at the start of each
request, which applies pending notifications and then polls the revision once.
A multi-instance deployment needs a cache the instances share: Redis natively,
or the database-backed StoreCache over D1 or libSQL at the edge. The default
MemoryCache is process-local.
Settlement and metering
Every path that reaches an upstream ends in the same settlement funnel in the engine: price the exchange, charge every applicable budget window, and hand the usage report to the observer. Settlement happens exactly once per request. There is no faster path that skips it, because such a path would be unmetered traffic. Details:- Budgets are checked before the first attempt and settled with the priced cost after the upstream has answered. Because cost is only known afterwards, in-flight and concurrent calls can take a budget past its limit.
- Settlement is idempotent per request id. If settlement fails, the accounting is lost but the response already delivered to the caller is not affected.
- A model with no matching price rule settles at zero, and its usage carries the
dimension
unpriced = trueso the operator can find it. - Usage is recorded per physical upstream call.
- Optional work, such as request capture, is decided before the request runs. When capture is off, no body is cloned and nothing is allocated for it. (v3 did the work first and let the sink discard it.)
StoreObserver, writes to the database, and a host can
replace it.
Building for WebAssembly
gproxy-app and the axum router both build for wasm32-unknown-unknown.
That is why gproxy-host-edge mounts the same Router instead of keeping its
own route table. axum’s server parts (http1, http2, tokio) are Cargo
features, and Router itself is target-independent, so what the wasm build
leaves out is socket handling, not routes.
The obstacle is Send. On wasm the engine is deliberately !Send: a JS
transport handle belongs to the isolate that created it, and
gproxy-client’s ClientBounds is Send + Sync natively and empty on wasm.
axum, however, requires Send + Sync router state and Send handler futures.
Two bridges reconcile this, both relying on a Worker isolate being
single-threaded:
gproxy_app::Appholds its sdk handle in asend_wrapper::SendWrapper.gproxy_host_axum::sendwraps each handler body (and each streamed response body). Natively it is the identity function; on wasm it is aSendWrapper, which panics if polled from another thread.
send fails to compile on the wasm target, and the
error names the handler. This is why the wrapper sits at each handler and not
behind a cfg on the router: a route that cannot compile for the edge cannot
quietly be missing there.
On wasm the default cache is StoreCache and sync is always manual (see
above). Native-only features (PostgreSQL, MySQL, local file storage, proxies,
TLS emulation) are not available; libsql and the D1 binding are the edge
databases. For building and deploying the Worker, see
Building from source.
Design rules
When you change the code, keep these properties:- The engine does not depend on a server framework, a router or a UI.
- Every request that reaches an upstream exits through the settlement funnel.
- Conversion is pairwise between vendor dialects, with no intermediate representation. A converter reads only declared source fields and never reads or writes the unknown-field bag.
- A configuration write is one transaction together with its revision bump.
- Snapshot publication is monotonic; a reload never goes backwards.
- Assembly does not fail because of one bad row. The row is skipped, counted and logged.
- The edge host has no route table of its own.
- Frontend types are generated from Rust (see Type export) and never written by hand.