v1.8.0
Changelog
All notable changes to SBproxy v1.x. Versions before v1.0 shipped as the
Go implementation and now live in the archived
soapbucket/sbproxy-go
repository.
[Unreleased]
Work that has merged to main since the latest tag and is queued for
the next version cut.
[1.8.0] - 2026-07-27
Trust tier becomes live policy input, config authority grows a command
line, and the admin console gains the pages it was missing. This release
also moves the vendored Pingora fork onto upstream 0.8.1, which carries
security fixes; see Security below.
Security
- Pingora updated to upstream 0.8.1. The vendored fork was based on
0.8.0 and has been rebased onto 0.8.1, picking up an HTTP/2 server
limit bound that mitigates a memory-exhaustion vector, plus the fixes
forRUSTSEC-2026-0098andRUSTSEC-2026-0099. Every deployment
terminating HTTP/2 should take this release. SBproxy's three local
patches (dynamic rustls cert resolver, the
upstream_response_decisionretry hook, and the refusal to retry once
response bytes have reached the client) are unchanged.
Added
- The admin console reports context compression. A Compression page
lists the sessions whose history has been externalized to a summary,
with tokens covered, summary size, and the resulting ratio. Summary
text is never listed, only its size and provenance. - The admin console reports who can sign in. A Users page lists each
account and its role over a new read-onlyGET /api/admin/users.
Accounts remain config (admin.username,admin.operators), so the
route reports and does not mutate, and passwords are never included in
the response. - Spend links through to the requests behind it. Origin rows in the
spend breakdown open the request log filtered to that origin. - Trust tier is now live policy input. The request path combines
authentication and agent-detection evidence intosuspicious,strong,
named, oranonymous; CEL expression and assertion policies can read
request.trust_tier, andsbproxy_trust_tier_requests_totalreports the
closed-set distribution. Verified Web Bot Auth resolves tostrong. - Operate a config authority from the command line. Running one used
to mean hand-rolledcurl.sbproxy config authority initgenerates
the Ed25519 signing key owner-only, writes the verifying-key file
subscribers install, and prints what to copy where; it refuses to
overwrite an existing key, and--forcerotates by adding the new
verifying key beside the old one so subscribers keep verifying while
they are updated.publishruns the same three validation steps the
authority runs, through the same code, so a payload that would be
refused is refused locally before a revision number is spent on it.
statusshows the current revision, the key id, and every subscriber's
last-seen revision, which is fleet drift visible from a terminal.
rollbackrepublishes the previous revision's payload under a new
revision number, because a subscriber's anti-replay cursor refuses
anything that does not move forward.subscriber add | list | revoke
manages credentials, andaddprints the credential exactly once and
says so. Every command that changes what the fleet sees goes over the
admin API and reports what the server returned, and an unreachable
authority is a distinct non-zero exit rather than something local that
looks like success. New admin route:
POST /admin/config-authority/rollback. - Preview the configuration an authority would push, before it lands.
sbproxy config pull --dry-runruns a real subscriber cycle up to the
point of applying: conditional fetch, signature and digest and replay
verification, the merge over the local document, and the
unresolved-${VAR}screen. Then it prints the plan diff and stops. The
bundle cache is not written, the replay cursor is not advanced, and
nothing reloads. - Subscribe to signed configuration from an upstream authority. A new
proxy.config_authority.upstreamblock points a node at an authority
that publishes signed configuration bundles. The node polls, verifies
the signature against the keys it trusts, merges the payload over its
own file, and applies the result through the same reload transaction a
SIGHUP takes, so a bad bundle is rejected before anything is published
and the previously applied configuration keeps serving. Paths that
describe the box rather than the fleet are refused outright: listeners,
TLS material, the admin surface, secret backends, cluster identity, and
the authority block itself. A monotonic cursor refuses a replayed or
rolled-back revision, including across a restart, and the verified
bundle is cached so an unreachable authority costs nothing but a
climbing staleness gauge.mode: overlaymerges over the local file;
mode: replacetreats the bundle as the configuration and will not
start without one. Bundles that still reference an environment
variable the node does not set are refused rather than applied as
literal text, because nobody is reading the log on a hundred machines
at once. New metrics:sbproxy_config_bundle_revision,
sbproxy_config_bundle_age_seconds,
sbproxy_config_bundle_fetch_total,
sbproxy_config_bundle_applied_total, and
sbproxy_config_bundle_applied_degraded_total. - A response-cache store you can pick. The response cache has had
four storage backends for a while, but only one of them was reachable:
nothing in the pipeline built the others, so no config could ask for
them. The new top-levelproxy.response_cache_storeblock selects
memory,file,memcached, orredisand the pipeline builds what
it names.filegives you a cache that survives a restart and can be
shared by replicas pointed at one directory;memcachedgives you a
shared cache without standing up Redis. The block sits underproxy
rather than on an origin because one store serves the whole process,
and every origin withresponse_cache.enabledshares it. Leave it out
and nothing moves: the store is still Redis whenl2_cache_settings
is configured and an in-process map otherwise. See
docs/configuration.md. - Encryption at rest for cached responses. An
encryptionblock
underproxy.response_cache_storeseals cached headers and bodies
with AES-256-GCM on the way to whichever backend you chose, so a
cache directory or a shared memcached is no longer a plaintext copy
of everything your upstreams returned. The key is a secret reference
like any other in the config, so it stays out of the config file, and
it should be 32 random bytes rather than a passphrase.previous_keys
covers rotation: new writes seal under the active key while retired
keys keep opening older entries. There is no plaintext fallback. A key
that cannot be resolved stops startup instead of quietly caching in
the clear, and an entry that fails its integrity check is evicted
rather than served. Runnable example in
examples/response-cache-encrypted/. - Local classifier-based routing. A
type: classifierinput guardrail
embeds a prompt with a verified local ONNX model, chooses the nearest
configured class centroid, and publishes the label to
ai.guardrails.labels. CEL can turn that label into
route_to:<model>, so the gateway routes on request intent without
sending the prompt to a classifier service. Invalid or unresolved
classifier artifacts remain inert, and score and margin thresholds prevent
ambiguous labels. See
docs/ai-gateway.mdand the
runnable
examples/ai-classifier-routing/.
Changed
- A reload that fails now really does change nothing. Reloading a
config installed a dozen pieces of process state (log redaction,
cardinality caps, log sinks, the AI provider catalog, the key plane,
detection singletons, Lua sandbox limits) before it got to the two
steps most likely to reject the config. So a config that parsed but
failed to build left the box running the new redaction rules and the
new AI catalog against the old pipeline, while the log line said the
previous config was still serving. Everything that can refuse a config
now runs first, and nothing installs until every one of those checks
has passed.POST /admin/reloadalso reports what happened rather
than only whether it worked: the response carriesfully_appliedand,
when a subsystem loaded with stale state, adegradedlist naming it.
A handful of subsystems are still allowed to fail without refusing the
reload, because a stale AI catalog beats a proxy pinned on an old
config, but they can no longer fail silently. - Changing
proxy.secretsis refused instead of ignored. The secret
resolver owns live connections to Vault, AWS, GCP, or Kubernetes and
is built once at startup, so a reload never actually rebuilt it. The
change was dropped on the floor and the first reference to a
newly-declared backend then failed at handler construction with an
error naming the reference rather than the cause, long after the
reload had reported success. Such a reload is now rejected outright
with a message saying a restart is required, the way a cluster
identity change already was. Rotating a secret inside your vault still
needs no restart; only changing where SBproxy looks does. See
docs/secrets.md. - The admin server no longer boots wide open on default credentials.
admin/changemeexists so a first run works, but nothing stopped
it from being the credential on an admin API bound to0.0.0.0with a
private-range allowlist and no TLS, which is a published password in
front of key minting and config writes. Validation now refuses the
default password when the surface is reachable from another host,
meaningbindis not a loopback address orallow_ipscontains an
entry outside loopback, and the error names which of the two tripped.
Loopback with the defaults is untouched, since that is the local
development path. Three related soft spots went with it: an empty
allow_ipsdenied nothing at the type level (the safe loopback-only
default lived in anifat the one call site, so the filter itself
was fail-open), loopback was matched by comparing text so an
IPv4-mapped peer such as::ffff:127.0.0.1was turned away from a
loopback-only server, and an unparseablebindsilently fell back to
127.0.0.1rather than failing, which made a typo in a wide bind look
like it had worked.sbproxy planalso stops describing
proxy.admin.**as a reload:AdminConfigis read once at startup, so
a rotated admin password or a swapped certificate needs a restart, and
the plan now says so. Seedocs/admin.md. - Accepted configuration now has an accountable runtime owner. GraphQL
depth, introspection, and syntax controls are enforced before upstream
dispatch; configured CEL feature flags publish atomically across reloads;
concurrent limits can be keyed by client, API key, header, or route; and AI
shadow requests run through a bounded, drop-on-saturation lane that cannot
delay the primary response. Enabling the reserved HTTP/3 listener now fails
configuration compilation instead of logging and continuing without QUIC.
A build-time schema audit rejects future keys that have neither a production
reader nor an exact reviewedConfigOnlyjustification. - Workspace rate-budget behavior now has one owner. The
rate_limit_budgetpolicy module owns the soft, throttle, and auto-suspend
state machine and its tests. The previously ignoredper_route_rpsfield is
now a config error; userate_limitingfor a per-route ceiling. The
headers.include_ratelimit_policyswitch now controls the corresponding
response header.
Fixed
GET /admin/driftno longer invents drift after a hot reload. The
baseline it compares against was recorded at startup and by
POST /admin/reload, but not by the file watcher or bySIGHUP. So
editing the config file and letting the watcher pick it up left the
running config correct and the baseline stale, and drift reported a
difference that did not exist until the next admin reload or restart.
Every path that loads a config now records the baseline.- Saving config from the admin console no longer leaks health probes.
Validating a config meant building the whole pipeline to see whether
every module would construct, and that construction spawned the active
health-check probes for any load-balancer target configured with
health_check. The pipeline was then thrown away, but the probes were
not: each one held the discarded pipeline alive and kept issuing real
requests at the upstream on its own timer, forever. Every save in the
admin console's config editor started another full set. An operator
iterating on a config could leave a target being probed by a dozen
generations of dead pipelines at once. Validation now constructs
without starting anything that outlives the check, and the admin write
path asks for a validation pipeline rather than a live one. The
validateandplansubcommands were never affected, because they
run outside an async runtime where the spawn was already a no-op. - Memcached cache keys are hashed. Memcached rejects a key longer
than 250 bytes outright, and a response-cache key carries the
hostname, path, query, and Vary fingerprint, so any reasonably long
URL produced a key the server refused. Those requests missed on every
single read. Keys are now hashed before they go on the wire. - Memcached TTLs are clamped at 30 days. The protocol reads any
expiry above 30 days as an absolute Unix timestamp rather than an
offset, so a longer configured TTL was stored as a moment in 1970 and
the entry was dead the instant it was written. Relative TTLs are now
capped at the protocol ceiling. - The file cache no longer discards entries it was asked to keep. A
stale-while-revalidate read deleted the entry it had just fetched, so
the grace window it existed to serve was gone after one request. - Concurrent file-cache writes no longer tear. Two threads writing
the same key shared one staging file and could interleave their bytes
into it, and the atomic rename then published the mixture. Each write
now stages in its own file.
[1.7.0] - 2026-07-22
The admin release. The console is rebuilt around the editorial brand
system, gains live sampled charts, and, most importantly, stops hiding
data the proxy was already collecting: request sessions, custom
properties, and the gateway's own decisions now reach the operator,
and the alerting engine finally has a face. Per-origin scoping runs
across the estate so a multi-tenant gateway reports per tenant.
Added
- Sessions. Requests carrying
X-Sb-Session-Id(and optionally
X-Sb-Parent-Session-Id) are reconstructed into logical
interactions. A session index ranks recent work by requests, tokens,
cost, wall-clock duration, and worst status, indenting child
sessions under their parent; a detail page reads one session's call
chain oldest first with each call's gateway decisions, identifiers,
AI route, tokens, cost, and properties. This is a view over the
in-memory request ring, not durable trace storage. - Custom properties as first-class dimensions. Bounded
X-Sb-Property-*headers are captured, redacted per configuration,
and carried on the request log, where they become filter and column
choices. Properties named in an origin'sproperties.rollup_keys
are promoted to durable spend dimensions, so the Spend page can
group a window by a business dimension the caller supplied. - Gateway decisions on every request row. The log now records what
the gateway actually did: cache result, retry count, whether
failover engaged and between which providers, the load-balancer
strategy and target, and the guardrail outcome. The console reads
them as one causal rail per row, answering whether the resilience
configuration fired without opening a body. - Alerts page. The alerting runtime is visible for the first time:
rule thresholds, current reading, sample floor, and evaluation
state; sanitized channel targets with delivery health and bounded
errors; and recent fired, resolved, and test events. A targeted
channel test exercises delivery without changing configuration.
sb.ymlremains authoritative and the page is read-only. - Live metrics. The Metrics page samples the Prometheus endpoint
and charts what happened between samples: request rate, error rate,
latency percentiles from histogram bucket deltas, and AI token
throughput, with numeric tiles and trend sparklines. - Per-origin scoping. The attributed AI counters and the durable
usage rollups carry the origin the request arrived on, and Metrics,
Spend, Cache, and Logs can scope to one origin. Panels whose series
have no origin dimension say so rather than showing unscoped numbers
under a filter. - Context-compression reporting. The compression policies report
compressed requests, tokens and cost saved, per-lever savings,
outcomes, and average ratio per lever.
Changed
-
sbproxy applynow actually applies to the running proxy. It used
to compile the config into its own short-lived process, swap that
process's pipeline, printapply: reloaded config from ..., and exit
without ever contacting the proxy. A running server picked the change
up only if its file watcher happened to notice the file, so exit 0 was
not evidence that the config had been accepted, or even seen. A config
the server would have rejected still exited 0. Apply now pushes the
config over the admin API and reports what the server did with it, so
the exit code means something: 4 if the proxy refused the config, 7 if
no proxy answered, 8 if it loaded but a subsystem kept stale state.
The admin endpoint defaults tohttp://127.0.0.1:9090and is
overridable with--admin-urlorSB_ADMIN_URL.This changes the contract. Apply previously needed no running proxy
and always exited 0; it now needs to reach one. If you callapplyin
CI as a validation step, switch it to the new--validate-only, which
runs every check and stops without contacting anything. That flag is
the honest name for what the old behaviour was actually doing. -
The admin console follows the sbproxy.dev editorial system.
Paper and ink surfaces, a persistent top bar carrying the admin
host, a live health dot, and the cluster node count, mono
microcopy, and square corners. Every mutation confirms or fails
through a toast; validation detail and revision conflicts stay
inline next to the form that caused them. -
The admin rate-limit default is 240 requests per minute per
client IP, up from 60, with the global cap still ten times that.
A busy console no longer trips its own limiter.
Fixed
- Cache hit and miss counts are no longer always zero. The Cache
page read a metric name the server never emitted. - The playground reaches locally served models. A chat against a
served or managed deployment returned 404 because the request
skipped the runtime's endpoint resolution and fell back to a
localhost URL pointing at the proxy itself. - Spend groups by a promoted property. The group-by parameter was
read without percent-decoding, so the console's own
property:<key>selection failed as an unknown dimension. - Spend history reports a disabled rollup store as a hint, not as
a failed view. - The overview lists managed models by name with their reserved
memory, instead of "unknown". - An engine that dies after reaching readiness reports why. The
health path now carries the bounded, redacted stderr tail into the
retained error rather than logging only that the process exited.
[1.6.2] - 2026-07-21
Added
- The local llama.cpp engine pin follows your macOS version. Pinned
builds now carry their measured minimum macOS, and the host selects the
newest compatible one: macOS 26 gets the current build, macOS 14 and 15
get the newest build published against the older toolchain. Previously
the single pin targeted macOS 26 and died at dynamic-link time on
anything older. A host older than every pin fails before download with
the versions named; an explicitversion:still wins.
Fixed
- Loading the admin UI no longer spends the admin rate budget. Static
UI bundle assets are exempt from the per-IP admin rate limiter, so
opening the dashboard cannot starve API polling behind 429s. sbproxy --versionreports the real product version instead of a
stale crate stub.- The installer reports the binary it just installed, not whatever an
earlier install left on PATH.
[1.6.1] - 2026-07-21
A point release fixing operational defects found immediately after the
1.6.0 cut.
Added
- Configurable admin rate limit.
proxy.admin.rate_limit_per_minute
(default 60, the previous hardcoded value; valid 1 to 100000). Automation
and dashboards that poll admin endpoints faster than once per second per
node can now raise the cap instead of silently reading 429s.
Fixed
- Docker images start again. The published linux binaries are built
against glibc 2.36 so the container runtime image can execute them. - Gateway-only clusters no longer report a standing pseudo-outage.
Nodes without the worker role are not graded on the model plane, so a
cluster of pure gateways shows healthy nodes in/admin/cluster/status
and dashboards instead of a permanent degraded state. Worker health
semantics are unchanged. - Model engine launch failures are diagnosable. A failed engine start
logs its bounded, credential-redacted stderr tail instead of holding it
only in memory, and the release certification artifact carries the boot
log and durable job records.
[1.6.0] - 2026-07-20
The cluster release. The mesh gains durable replicated state, governed
budgets that mean the same thing on every node, full
self-instrumentation, and a Kubernetes operator that forms it. Local
model serving grows a real deployment control plane and serves across
nodes, tensor-parallel GPU groups, replicas, LoRA adapters, and a
second Python engine. Two load-time behavior changes to note under
Changed: invalid retry_on entries and max_attempts above 16 now
fail the load, and sbproxy validate now fails a config that would
refuse to boot. The serve-related YAML fields remain unpinned, as in
v1.5.0.
Added
- Managed model deployments. Local serving gains a real control
plane: a canonicalmodel_host.deploymentsdesired state (existing
serve:entries lower onto it), content-addressed weight artifacts
with resumable sha256-verified pulls and protected LRU garbage
collection, durable deployment revisions and operation jobs, and one
process-wide runtime manager for atomic reload, warm rolling or
recreate rollouts with capacity preflight and rollback, admission,
keep-alive, idle eviction, drain, health, and crash-loop retention.
Operated through authenticated lifecycle APIs andsbproxy models pull / list / show / ps / stop / remove. - Governed multi-node model serving. A fleet of gateways serves one
model estate: constrained node enrollment with strict manual-PKI
identity verification, a model directory carrying the full node
roster with stable exclusion reasons and explicit unhealthy-node
callouts, deterministic capability-aware placement with rolling
handoffs, durable generation fencing, and signed deployment-authority
state. A dedicated private HTTP/2 model plane (production mTLS,
signed one-hop dispatch envelopes, bounded replay protection) routes
governed requests across current-generation local and peer replicas
with coordinated cold starts, streaming backpressure, client
cancellation, and failover only before any client output. Model
discovery stays OpenAI-shaped and topology-free. - Tensor-parallel groups and N replicas per node. The fit planner
searches tensor-parallel degrees 1, 2, 4, and 8 over homogeneous GPU
groups and picks the smallest degree at which a candidate quant fits,
so a model larger than the largest single card (a 70B at fp16 needs
about 140 GB) shards across a group instead of being unservable. A
deployment can also run several replicas of one model on disjoint
device sets of the same node, so a dense GPU box no longer idles its
other cards; asking for more replicas than the node can hold fails
with a reason naming the shortfall. - The fit planner understands model shape. Catalog entries carry a
modality(chat,embedding,rerank,speech_to_text,
text_to_speech,image): a non-decode model stops being charged
autoregressive KV-cache VRAM, vLLM launches an embedder in embed
mode, and a locally served embedder answers/v1/embeddingsinstead
of a blanket 501. A mixture-of-experts model that does not fit VRAM
whole keeps attention, shared, and dense tensors on the GPU and
spills the fewest whole expert layers to CPU RAM (llama.cpp's
--n-cpu-moe), which is how a 30B-A3B-class model runs on a 12 GiB
card. The planner also predicts decode throughput per placement,
calibrated against live A100 measurements. - SGLang engine driver.
engine: sglangserves safetensors models
on CUDA through SGLang, acquired viauvxor a digest-pinned
container and dispatched over the same OpenAI shape as vLLM. vLLM
stays the default; SGLang is a one-line opt-in for prefix-heavy agent
traffic, where the measured head-to-head favors it. The benchmark
behind that guidance is published in
docs/serving-engine-benchmark.md. - Container engine provisioning is the default when a runtime is
present. Standing up vLLM from a bare host environment needs its
whole build toolchain and fails in a cascade on a stock GPU box, so
when docker or podman is on PATH and the operator has not configured
provisioning, the Python engines (vLLM, SGLang) now provision from
curated digest-pinned container images, the exact digests validated
on real GPU hardware. The hostuvxpath remains available by
configuration. - The embedded in-process engine moves to mistral.rs 0.9
(PagedAttention default-on for CUDA, CUDA graphs, FlashInfer). The
dependency stays opt-in and off by default. - Accurate prompt token counting with a pre-flight context-fit
gate. Locally served models count prompt tokens against the
model's own tokenizer (prefetched alongside the weights, parsed once,
cached) instead of a chars/4 heuristic, and an over-context prompt is
rejected before dispatch with a clear error instead of failing
opaquely inside the engine. - LoRA adapters over one resident base model. A vLLM serve entry
withlora_adapterslaunches the base model with each adapter
registered by name, so a client requests a fine-tune by name over one
resident base instead of paying for a separate engine per fine-tune.
vLLM-only for now; other engines reject the fields with a clear
reason. - Per-deployment engine tuning and version pins. Canonical managed
deployments carry the engine tuning knobs (chunked_prefill,
including a TTFT-target mode that derives the batch size,
tool_call_parser,swap_space_gib,cpu_offload_gib,
extra_args), and the vLLM passthroughs now actually reach the
engine instead of being rejected at prepare. A deployment can pin its
ownengine_version/engine_image/engine_sha256over the
node-wide engine policy, so two models on one node can run different
vLLM versions (canary an upgrade on one model, hold another to its
certified version);latestversions and unpinned images are
rejected at config validation, and the served engine version surfaces
in deployment status. - Per-completion local-vs-cloud savings. A serve entry can declare
the hosted model it displaces and that model's per-million-token
price in areference:block; every completion the local model
serves is priced at the reference into a durable ledger, and
GET /admin/model-host/valuereports completions and dollars saved
per model. Explicit config only: no reference means no savings claim,
never a guessed cloud price. sbproxy updateacts on stale artifacts. A plain run now
fetches, verifies, and atomically swaps a stale engine prebuilt, and
--selfreplaces the sbproxy binary from its release channel;
--checkkeeps the report-only behavior. A pinned artifact, or one
managed elsewhere (apath, brew, or apt engine), is reported and
never mutated; the newupdate.{channel, auto, check_interval}
block configures it, andautoonly ever reports in the background.- Weight-cache and artifact management. The admin plane gains a
verified-artifact inventory (GET /admin/model-host/files),
fail-closed artifact deletion, on-demand garbage collection, per-node
cluster artifact totals, and a Storage view in the admin UI. A cache
miss can reuse a discovered Ollama, LM Studio, or Hugging Face cache
read-only instead of re-downloading weights.sbproxy models lock
pins resolved artifacts to a lockfile,models verify-lockreports
drift, and--lockedrefuses to serve anything off-lock.sbproxy models prunereclaims content-addressed weight blobs no cached
artifact references. - Served-model priority lanes.
serve.max_concurrent_requestscaps
in-flight requests into a local engine behind a queue ordered by the
calling key'sprioritylane (interactive,standard,batch),
FIFO within a lane, so a batch flood cannot starve interactive keys;
an interactive request that would queue spills immediately to the
next non-served provider when one exists. The lane binds to the key
record, never a client header. - Governed key policy enforces end to end. One canonical
effective-policy contract covers configured and dynamically stored
keys, and lifecycle, tenant, model, provider, route, principal, PII,
tool, prompt-injection, rate, budget, and admission policy all act on
the live request path; admin mint, preview, and revisioned PATCH are
fail-closed and the Keys UI is driven by the server's schema. Keys
gain a working per-key tokens-per-minute cap, a priority lane,
inject_mcpon dynamically stored keys, and PATCHable metadata, and
immutable key and attribution dimensions propagate through usage,
access logs, metrics, traces, and bounded audit events. - Cluster-coherent governed-key budgets. A governed key's request,
token, and cost limits enforce through a reserve-then-settle flow on
the live AI path and mean the same thing on every gateway node, in
two tiers: approximate (the default; each node disseminates settled
usage over the mesh and admission weighs the whole fleet's spend
within a bounded staleness window, no external database) and strict
(atomic reserve and settle against a shared Redis backend, so two
nodes cannot both admit a request only one has budget for). Strict
without a Redis backend fails config validation. - MCP guardrails. Deterministic OpenAPI-derived egress policies
with redirect-target validation, lethal-trifecta session risk
tracking and enforcement, opt-in dual-LLM quarantine, run-as-user
credential minting that carries the caller's own Authorization on the
federation wire, token compaction, and a supervised local stdio MCP
transport. - Traffic governance fills out, and LiteLLM import stops dropping
keys silently. OTel, S3, and GCS usage sinks join the existing sink
set; purpose-scoped egress, quota headroom- and reset-aware routing,
and local fair-share pools land alongside them.config import-litellmnow classifies every unknown key as mapped, warned,
or unsupported instead of silently dropping it, and known sink
callbacks andmax_budgetemit real config. - Durable replicated cluster state.
proxy.cluster.replication
turns the mesh's single-owner in-memory state into a replicated,
durable substrate: each key maps to a preference list of nodes on the
existing hash ring, writes and reads chooseone,quorum, orall
consistency with read repair, every replica persists write-through to
redb so an owner restart loses nothing, deletes replicate as
tombstones collected only after every replica confirms them and a
grace period passes, and fleet admin runs over topology-safe bounded
pagination. - The mesh reports on itself. Gossip probe round-trip time and
indirect-probe retries, enrollment outcomes, transport RPC errors by
phase and durations by operation, owner-routing outcomes, and a live
peer-count gauge; every mesh metric now sits in the executable
stability catalogue under the sanctionedmesh_prefix. - The Kubernetes operator forms the mesh. With
spec.clustering.enabled, the operator reconciles a StatefulSet
(stable per-pod identity, one-peer-at-a-time rolling restarts), a
headless Service publishing the gossip and transport ports, a
shared-key Secret, and a renderedproxy.clusterblock with
full-ordinal seed lists and per-pod node identity, built through the
typed config so invented fields are impossible. Includes the two
fixes live validation on kind surfaced: the operator now installs its
TLS crypto provider (it previously panicked on its first handshake
and reconciled nothing), and DNS-name gossip seeds resolve before the
probe path (they were silently skipped, leaving every pod a one-node
mesh). - Compression session state can live on the mesh. Stateful
compression'sstate.backend: meshnow runs on the durable
replicated substrate: conditional versioned session commits with
deterministic cross-node conflict resolution, tombstoned deletes that
survive partition and heal, and the same admin list, inspect, and
purge over fleet pagination. Redis remains the default and
recommended backend. - Measured 3-node cluster benchmark.
docs/performance.mdgains a
clustered section from a real 3-node GCP mesh run: forming the mesh
costs within noise on a single node (43,129 vs 43,958 requests per
second), three nodes sustain 119,178 requests per second aggregate
with zero errors, governed spend becomes visible on a peer in 15 to
20 seconds, and survivors run at 100% success through a mid-run node
kill, with rejoin about 10 seconds after restart. - Request-selectable AI context compression. Declare named route-local
profiles and explicit input budgets, then select them through
X-Compression, governed virtual keys, or CEL with deterministic precedence
and safe invalid-selector behavior. Phase 1 addsrag_select,
compact_serialization, andposition_reorderfor explicit line-delimited
retrieval blocks. The levers use deterministic ranking, reversible
sbproxy_table_v1encoding, closed fail-open outcomes, and semantic-cache
bypass before the finalwindow_fitbound. Stateful summaries use Redis as
the canonical session store while request workers remain stateless;
authenticated Admin APIs list, inspect metadata for, and purge that state.
Per-lever results now appear in bounded metrics and one content-free summary
event per executed pipeline; reducing levers also feed bounded value metrics,
dashboards, and the model-host value report. Live request-path acceptance and
five independently authored structural smoke reports cover the production
stateless pipeline. - MCP tool rollout plane. Publish several versions of one tool at once
and roll out breaking changes without breaking callers: arollout:block
under themcpaction'stool_versioningdeclares versions, where each
routes, and who gets which. Resolution walks a ladder (per-call_meta
requirement, per-session requirements declared atinitialize, operator
pins on the authenticated principal,search_v1-style catalogue aliases,
then the default), all as semver ranges. Old versions can route to the
upstream that still serves them or run JavaScript request/response
adapters against the new one, carry a sunset date that warns or blocks
past it, and every versioned call lands on
sbproxy_mcp_tool_version_calls_total{tool, version, via, deprecated}so
migration is observable.tools/listadvertises the consumer's resolved
version per tool with the available versions and sunset in_meta;
results carry the version that served them. Thetool_versioning.lockfile
is now optional so the rollout plane works without the version-bump gate.
Seedocs/tool-versioning.mdandexamples/mcp-tool-rollout/. - Model deployment management in the built-in admin UI. Operators can
browse catalog evidence, add or edit the complete desired deployment map,
resolve revision conflicts explicitly, and run Load, Stop, or Reset. The
same UI respects file-managed, admin-managed, and signed cluster-authority
ownership; file-managed and verifier nodes stay read-only. - Cluster operations and unhealthy-node alerts in the admin UI. The
Cluster page now shows every node, placement and rollout state, deployment
authority, and prominent links to unhealthy roster entries. Health remains
visible when metrics fail, and the last cluster snapshot stays on screen
with a stale warning after a refresh error. - Streaming responses now run every built-in output guardrail, with
verdicts matching the buffered path. A per-stream session matches the
substring guardrails (injection, toxicity, jailbreak, content safety)
over a cumulative window of decoded deltas, so a pattern split across
chunk boundaries still blocks, and word-boundary rules never
false-block on split words. - Streamed tool calls are assembled per call and judged by the
agent-alignment guardrail as each call completes. Block mode holds
tool-call frames until their call is judged while text keeps flowing;
flag mode logs and counts without touching the stream. - Per-entry
stream_policy(chunk,close,off) on output
guardrails, plus new metrics:
sbproxy_ai_stream_guardrail_violations_total,
sbproxy_ai_stream_guardrail_skipped_total, and
sbproxy_ai_stream_guardrail_decode_fallback_total. - A TPOT histogram (
sbproxy_ai_inter_token_latency_seconds)
completing the TTFT / TPOT / throughput serving triple, OpenMetrics
exemplars on the AI latency histograms so a spike links to its
trace, and the OTel GenAI metric instruments
(gen_ai.client.operation.duration,gen_ai.client.token.usage)
mirrored over OTLP so GenAI-aware backends chart without relabeling. headers:on the telemetry block for authenticated OTLP export to
hosted backends. Values accept secret references that resolve at
boot and fail loud, apply to traces, mirrored metrics, and the
OTLP log sink, and are masked in config printouts. Every signal now
carries detected resource attributes (host, process, Kubernetes
downward API,OTEL_RESOURCE_ATTRIBUTES), with explicit
resource_attrswinning conflicts.- Durable windowed spend rollups: hour and day usage buckets that
survive restarts, a windowed/api/usage/spend
(window/group_by/from/to), and a spend-history section on
the admin Spend page. On by default with bounded retention; rows
carry no prompt content and no raw key material. - Access log AI columns:
cost_usd_micros(integer micro-USD) and
guardrail_category/guardrail_actionon every guardrail
intervention, mirrored onto the request envelope and the admin
request ring;/api/requestsacceptsguardrail_actionand
guardrail_categoryfilters. - Slack and PagerDuty alert delivery channels as formatters over the
existing webhook transport (PagerDuty trigger/resolve keyed on a
stable per-rule deduplication key), plus Prometheus alert examples
for AI budget utilization, provider error burn, and spend velocity. - MCP
execute_toolspans following the OTel GenAI agent
conventions, parented into the caller's trace so agent request,
tool dispatch, and LLM calls render as one tree; the AI request
span emits tool-call span events (ids and names always, arguments
only undertrace_content). - Admin views: AI performance (TTFT / TPOT / throughput and provider
health with failovers, cascade tiers, and router decisions), Spend
(live attributed cost, token, and request breakdowns by model,
provider, key, team, and project),
Guardrails (blocks by category and wasted tokens / spend by kind),
live tail on the Logs view with full-record row expansion and
operator-configurable trace deep links
(admin.trace_url_template). - Executable capability registry. SBproxy's claims about itself are
now checkable code: every capability claim carries a support level,
nothing may be called stable unless a test proves a production caller
consumes it, and config-only is the honest, permitted name for a
surface that parses and does nothing. Build guards fail on a
published metric no code writes and on a tenant-relevant metric
family missing its tenant labels, and the shipped Prometheus alert
rules are validated with promtool in CI. This machinery surfaced the
availability SLO that read 100 percent forever and the
never-incremented metric families fixed in this release. - Getting started and framework integrations. A dedicated
docs/getting-started.md, install and quick start grouped together
in the README, and five framework one-pagers (LangChain, Vercel AI
SDK, Pydantic AI, Mastra, n8n) whose snippets were all executed
against a running gateway before landing. The README lede now leads
with what the gateway does today.
Changed
sbproxy validateruns the boot path.validate,plan, and
applynow construct the same compiled pipeline the server and
reload paths construct, so a config that would refuse to boot fails
validation instead of validating clean (measured before the change:
five published examples validated but refused to boot). Custom YAML
tags are rejected at compile: serde_yaml strips unknown tags, so a
password: !env ADMIN_PASSWORDsilently became the literal string
ADMIN_PASSWORD; the error now points at${VAR}interpolation,
and${VAR:-default}fallbacks work.- Status-code upstream retries moved onto a dedicated decision
hook. The retry decision fires on the pinned Pingora fork's new
upstream-response hook, once per upstream response and before any
bytes reach the client, replacing the response-filter workaround;
connect-time and status retries now share one attempt counter and
cap. Load-time validation is a behavior change: aretry_onentry
must beconnect_error,timeout, or a status in 100..=599 (junk
entries used to deserialize and silently never match; they now fail
the load naming the entry), andmax_attemptsabove 16 is rejected.
Retries land on
sbproxy_upstream_status_retries_total{origin, status}.
Fixed
retry_on: timeoutis honored, in both upstream phases. The
token was accepted and documented but nothing consulted it. A
connect-phase timeout now retries under eithertimeoutor
connect_error, and an established-connection upstream read or write
timeout retries when the policy allows it, sharing the same attempt
cap, and only when the request is replayable and no response bytes
have reached the client. The fork's retry loop also gains a backstop
refusing any retry after response bytes were sent, regardless of what
marked the error retryable.- Redis L2 connections keep their TLS, AUTH, and database
semantics.redis://andrediss://URLs preserve ACL and
percent-encoded credentials, IPv6 hosts, the selected database,
private CAs, and mutual TLS uniformly across the general L2 store,
compression state, and admin paths, compiled once per config
generation into an immutable connection snapshot. The blocking
plaintext RESP path is replaced with a real client, and connection,
TLS, authentication, and command failures classify without leaking
endpoints or credentials. - Cross-node mesh RPCs no longer stall about 40 ms on Linux. The
transport wrote a frame's length prefix and body as two separate
writes and never set TCP_NODELAY on accepted sockets, so Nagle plus
the delayed-ACK timer held every response leg. Frames now leave as
one write and the server sets nodelay on accept; a small-frame
replica fetch drops from about 41 ms back to sub-millisecond. - The MCP gateway speaks the spec's camelCase on the wire.
initializeresults, tool results, and tool annotations serialized
as snake_case (protocol_version,is_error,read_only_hint),
which the official TypeScript SDK's schema rejects outright, so a
strict client could not connect at all and tolerant clients silently
dropped tool error flags. Serialization is now camelCase; snake_case
still parses so results from older nodes survive mixed-version
rollouts. - Raw
hf:references serve through the live path. The production
runtime manager only resolved fully pinned catalog artifacts, so a
rawhf:Org/Reporeference in aserve:block failed reconciliation
and, in practice, no open-weight model could be served on a GPU from
the gateway. Raw references now resolve, pull, and serve end to end,
validated on real multi-GPU NVIDIA hardware across vLLM, SGLang,
embeddings, and tensor-parallel launches. - SGLang serving hardening. The launcher passes a runtime-owned
memory fraction so SGLang no longer OOMs at launch; liveness probes
hit a non-generating endpoint instead of one that generated tokens
(and returned 503 under load); one transient health-probe miss no
longer kills a ready engine; and the probed SGLang version is
recorded on the provisioned engine. - Self-host admin edges. Attributed AI token and cost metrics now
populate/api/usage/spendfor locally served providers; direct AI
responses no longer log status 0 when a real response was written; an
upstream-TLS native-certificate failure on macOS is an actionable
startup error instead of a panic; and the admin Keys UI submits the
backend's full key-policy shape. - The
alerting:block alerts, and declared metrics record. The
alerting config parsed and silently discarded its settings; it now
drives a live dispatcher (delivering through the Slack and PagerDuty
channels above). The response-cache hit/miss, circuit-breaker
transition, and guardrail-block families were declared and scraped
but always zero; they are now written by the live request path, and
the metric drift guard follows aliased writers it was blind to. - The release provenance push no longer clobbers the SBOM
attestation. The provenance step replaced the image's attestation
tag wholesale after the CycloneDX attest step, so
cosign verify-attestation --type cyclonedxfailed on every
published image; the jobs are reordered so the SBOM attestation
appends last, and the offline verification recipe in
SUPPLY-CHAIN.mdnow works with current cosign.
Removed
- The unattributed AI metric families
sbproxy_ai_requests_total,
sbproxy_ai_tokens_total,sbproxy_ai_cost_dollars_total, and the
per-virtual-key trio. They were registered but never written on the
live path, and counter series register lazily, so no released
binary ever exposed a sample under these names. Consumers read the
attributed families; details in docs/metrics-stability.md. - The Go-era
secret:<name>colon form. It resolved through a
logical-name map with an environment fallback and was superseded by
the provider-URIsecret://<backend>/<name>schemes; a stale
reference now fails config load with a migration pointer instead of
resolving through a side channel.proxy.secrets.mapstill parses
for schema-v1 compatibility and warns at boot that it has no effect. - Dead mesh scaffolding and write-only key counters. The
unreferenced leader-election, health-monitor, consistency, and
membership-protocol modules are gone (live membership is the gossip
loop), along with a legacy wire variant only tests constructed and
the per-request mesh key counters that were incremented on every AI
request but never read anywhere. Governed-key budget enforcement is
unaffected, and the AI hot path now does no counter work at all. - Two dead metric families:
sbproxy_dedup_cache_size(registered,
never written, no readers) and the hostname-keyed
sbproxy_cache_hits_totalduplicate; the overview dashboard reads
the now-livesbproxy_cache_results_totalinstead.
[1.5.0] - 2026-07-08
Model serving lands: run open models on your own GPU behind the same
gateway that fronts the 66 hosted providers, plus the engine-acquisition
and self-host work queued since v1.4.0. No promises about backward
compatibility for any of the new YAML fields below until a later version
pins them.
Changed
- Duration strings parse consistently everywhere. The
ms/s/m/h/d
units, compound forms like1h30m, decimals like1.5h, and a bare
number (seconds) are now accepted by every duration field, instead of
each config block supporting a different subset (so a value like1h
that parsed in one block and errored in another now works in both). This
only widens what is accepted; no previously valid value changes meaning. - Unresolvable upstream hosts always fail closed. The upstream SSRF
guard no longer blocks the request worker on a per-request DNS resolve
(it resolves asynchronously now), and as part of that an upstream host
that fails to resolve is uniformly rejected, closing an edge where an
origin with a private-CIDR allowlist could previously fail open.
Removed
- Two rate-limit config options that parsed but never enforced anything
are gone. A virtual key'smax_tokens_per_minute(and the credential
policy'stpm) and an origin's per-originrate_limits:block both
compiled and round-tripped but were never read at request time, so an
operator who set them believed they were capped when they were not.
They are removed rather than wired. Existing configs that still set
these keys keep loading (the keys are ignored). The live limits are
unaffected: the top-level workspacerate_limits:budget, and the AI
gateway'smodel_rate_limits/ per-surface limits, all still enforce. - Two build-only feature flags that nothing enabled were removed
(sbproxy-platform/postgres-storeand an unusedsbproxy-modules
rate-limit feature), along with roughly 4,300 lines of verified
zero-caller internal code. No shipped configuration or public API
changes; the redb/SQLite storage stack is unaffected.
Added
- vLLM, provisioned with
uvx. vLLM is a Python package, not a
single-binary release, so sbproxy now acquires it by fetchinguv
(Astral's single-binary package manager) and running the engine through
uv tool run(uvx): a cached, ephemeral environment that uv sets up
on first use, bringing its own Python if the host lacks one. The default
wheel is CUDA-enabled, so a safetensors model offloads to an NVIDIA GPU
on a box that carries only the driver. Opt in with
engines.vllm.acquire.source: uvx;sbproxy run <model>sets it for
you.sbproxy doctorreports it as the recommended vLLM path. sbproxy update: is any of it out of date. A dry-run freshness
report:sbproxy updatechecks the inference engine release feed (the
pinned llama.cpp prebuilt vs the latest) and the cached models (flagging
any that track a moving ref likemainand could be behind upstream);
--selfalso checks the sbproxy binary against its release channel.
--jsonfor tooling. Reports only, nothing is mutated; a pinned artifact
is never swapped without an explicit run.sbproxy config print: see the effective config, with secrets
masked. Prints the config after built-in defaults + the file +
${ENV}interpolation, so it is obvious what a box will actually do.
Inline secret values (anapi_key,client_secret,token, ...) are
masked; secret references (vault://,${ENV},file:, ...) are
shown, since they are pointers, not the secret.--jsonfor tooling,
YAML by default.sbproxy models list/show: discover what this host can run.
sbproxy models(ormodels list) prints one row per catalog model
with a real per-GPU fit verdict (reusing the same probedoctoruses),
the resolved engine, params, and cache status (cached / not-pulled).
sbproxy models show <id>prints the full entry: HF repo, source,
revision, sha256 digests, engine, pull policy, and quants.--jsonon
both for scripts and the admin UI;--catalog-filepoints at an
operator manifest. Resident / serving state needs a running gateway and
is not shown by this offline view.sbproxy run <model>: serve a model in one command, no YAML.
sbproxy run qwen3-14b(orsbproxy run hf:Org/Repo:Q4_K_M --name coder) synthesizes a minimal serving config, checks the model can run
on this host (the same detectionsbproxy doctoruses, so a model with
no viable engine fails now with a remediation instead of a later 502),
and boots the gateway with an OpenAI-compatible endpoint on loopback at
http://127.0.0.1:<port>(both the IP andlocalhostroute). The
engine and weights are acquired on the first request. Flags override
the port, engine, acceleration, and cache directory;--dry-runprints
the resolution and the synthesized config without serving.- Model pull honors manifest pins and works for safetensors/vLLM on a
fresh box. A model's weight pull now uses the manifestrevision
(was hard-codedmain) and verifies the per-filesha256when one is
pinned, so a digest mismatch fails the pull loudly instead of serving
bad weights. And a safetensors model served via vLLM now pre-fetches
itsconfig.jsonon first use, so it admits on a box that has never
pulled it (previously it failed with "no model metadata"). - sbproxy acquires the inference engine, not just finds it on PATH.
Aserve:block can now carry a per-engineengines.<engine>.acquire:
block: for llama.cpp,source: release(the default) fetches a pinned
ggml-org prebuilt for the host platform and acceleration
(accel: auto|cuda|vulkan|metal|cpu; on Linux a GPU build means the
Vulkan asset, since there is no upstream CUDA Linux prebuilt),
sha256-verified when a digest is pinned, whilesource: pathpoints at
an operator-installed binary for an air-gapped box. A host with no
engine now serves a GGUF model instead of failing at the first request,
and a bad acquisition (apathsource with no path, alatest
version) is rejected at config load, not at runtime. Engine identity
stays the allowlisted set (vllm,llama_cpp,embedded); only how
the binary is obtained is configurable. The gateway also detects a
container runtime now, soengine: autocan resolve to vLLM's
container path for safetensors weights. - The released binary is GPU-aware out of the box. The
gpu-nvidia
(NVML GPU discovery with annvidia-smifallback) andmodel-weights
(Hugging Face weight download) features moved into thesbproxy
binary's default feature set, so one downloaded artifact adapts to its
host: the NVIDIA driver library is loaded at runtime when present,
never linked, and a GPU-free host still runs the same binary (a
serve:provider rejects admission cleanly there). Building with
--features gpu-nvidia,model-weightsis no longer needed for local
model serving. Library consumers of the workspace crates still opt in
per crate. sbproxy doctoris the self-host front door. The subcommand now
reports the full picture of what the binary can do on this host and
how to make it serve: OS and arch, CPU and RAM, free disk in the cache
directory, the GPU (or CPU / unified-memory budget) theserve:
admission path sees, NVIDIA driver and CUDA / Metal / ROCm, container
runtimes and daemon liveness, package managers, Python and uv, and
Hugging Face reach plus whetherHF_TOKENis set. For each engine
(llama.cpp, vLLM, embedded) it lists what is installed (with version)
and which acquisition sources are viable here, each with a reason.
Pass a config file (sbproxy doctor sb.yml) and it adds, perserve:
model, whatengine: autoresolves to and a coarse fit preview, and
exits non-zero when a configured model has no viable engine.
--format jsonemits a stable machine-readable report; collection is
read-only.- Local model serving runs on Macs and CPU boxes, not just NVIDIA.
The fit planner used to see zero devices on anything but an NVIDIA GPU,
so aserve:block on a Mac or a GPU-less server rejected every model.
The GPU probe is now layered: NVIDIA discrete GPUs first, then Apple
Silicon unified memory (reported as the working-set budget), then a CPU
budget sized to a fraction of system RAM. A small GGUF is admitted
against unified memory or RAM and served by llama.cpp or the embedded
engine; FP8 and other datacenter quants are still refused on hardware
that lacks the kernels. SetSBPROXY_CPU_MEMORY_FRACTION=0to opt back
into rejecting admission on a GPU-less host. The weight cache defaults to
~/.cache/sbproxy/modelsfor a non-root run (and the service path
/var/lib/sbproxy/modelswhen running as root), so serving works out of
the box without configuringcache_dir. - Serve-preflight warnings at config load. A config that declares
serve:on a host with no visible GPU, or with a serve entry whose
engine has no binary and no container runtime, now logs a warning at
startup and on every hot reload naming the model, the resolved
engine, and the blocker, instead of degrading silently until the
first request fails over.
Changed
- A forward rule whose header matcher names an invalid HTTP header now
fails at config load. Theheader:matcher on aforward_rules:
entry precompiles its name at load time; a name that is not a valid
header (for example one containing spaces) previously loaded and then
silently never matched, and now reports a clear error at load and on
reload. Valid configurations are unaffected.
Fixed
-
Revoking a key now blocks OIDC/JWT identities mapped to it. With
key_management.oidc_claim_mapconfigured, a verified token whose mapped
claim named a revoked, blocked, or expired record was silently downgraded to
an ungoverned request (no per-key policy) instead of being denied. The
mapped-claim path now mirrors the bearer path: an inactive record denies with
403, a claim naming a missing record denies with 401, and a store outage
fails closed unlessfailure_mode_allowis set. Tokens that carry no mapped
claim are unaffected. -
Error responses now emit valid JSON when the message contains a quote or
backslash. The sharedsend_errorhelper and the ledger, policy, and
storage error paths built the{"error": "..."}body by string
interpolation, so a message carrying a client-supplied value (for example a
rejected AImodelname) could break the JSON envelope or inject a sibling
field. Every error body is now serialized, so the message is always escaped. -
JSON threat protection now scans the whole request body. The depth, key,
and size checks read only the first body chunk, so a JSON payload whose
oversized structure began past the first chunk could slip past the scan while
the full body still reached the upstream. The scan now accumulates the
complete body, bounded bymax_total_size(or a hard ceiling when unset),
before validating it. -
The in-memory idempotency cache and the native SSE reassembly buffer are
now bounded. The single-instance idempotency store grew without limit under
unique keys and is now a capacity-bounded LRU. The native streaming framer
buffered upstream bytes until a frame boundary and now caps the reassembly
buffer, so an upstream that never closes a frame cannot grow it without limit.
[1.4.0] - 2026-06-27
Fourth minor release on the Rust v1.x line. Hardening and reach for the
AI gateway and the clustering mesh: mutually-authenticated TLS on the
peer transport, external HTTP guardrail providers on the request and the
response, native Langfuse and Datadog usage sinks, and per-server
namespace control for MCP federation. One correctness fix promotes
budget windows from parsed-but-ignored to enforced. No config-breaking
changes; existing sb.yml files compile unchanged, and every new field
is default-off.
Added
-
Mesh peer mTLS. The mesh peer transport can run over
mutually-authenticated TLS: setkey_management.cache.mesh.peer_tlswith
cert_file,key_file, andca_file(plus an optionalserver_name,
defaultsbproxy-mesh). Every inbound connection must present a CA-signed
client certificate and every outbound connection presents this node's
certificate, both verified against the CA, so an untrusted peer cannot join
the cache fabric. Plaintext when unset. -
Per-server namespace mode for MCP federation. A federated upstream can
setnamespace: alwaysto expose every tool as<prefix>.<tool>and every
resource as<prefix>/<uri>, where the prefix is the server'sprefix(or
a name derived from its origin). The default,on_collision, keeps bare
names and only qualifies one when it clashes with an earlier server. -
External HTTP guardrail providers. An AI origin's
guardrails.external
list runs external guardrail services alongside the built-in checks.
Input-mode entries (pre_call/during_call) inspect the request before
dispatch; output-mode entries (post_call/during_call) inspect the
non-streaming response before it is cached or sent. Either blocks on a
not-allowed verdict (logging_onlyrecords only), and a transport or parse
error honors each entry'sfail_openflag. Provider presets shape the
request and response for Presidio (/analyzewith a findings array) and a
generic{"input"}shape that fits Lakera, Aporia, and custom endpoints,
with an optional API key on a configurable auth header. Streaming-response
and AWS Bedrock (SigV4) guardrails are not yet wired. -
Native Langfuse and Datadog usage sinks. Alongside the JSONL-file,
webhook, and ledger sinks,usage_sinksnow acceptstype: langfuse
(hostplus public/secret key; posts a generation observation to
/api/public/ingestion) andtype: datadog(api_keyplus optional
site/service; posts to the logs-intake API). Both are
fire-and-forget and never fail the request they record. Object-store
(S3/GCS) and OTel usage sinks are not yet included.
Fixed
-
Budget windows now reset per period. A budget
limitwith aperiod
(daily,monthly, or a duration like30d) was parsed but never enforced
as a rolling window, so spend accumulated forever and a daily cap behaved
like a lifetime cap. Each limit now accrues against its own per-period
bucket, so a daily cap clears at the next day and a daily and a monthly cap
on the same scope are tracked independently. Cumulative limits (noperiod,
ortotal/lifetime) are unchanged. -
MCP federation now advertises the disambiguated name on a collision.
When two upstreams exported the same tool name, the gateway kept the
prefixed name only as an internal registry key while still advertising the
bare name, so the second tool was unreachable andtools/listshowed a
duplicate. The disambiguated name (<server>.<tool>, or<server>/<uri>
for resources) is now the advertised, routable name; resource reads still
forward the original upstream URI.
[1.3.1] - 2026-06-25
Patch release. Fixes TLS, which was broken on startup in v1.2.0 and v1.3.0.
Fixed
- TLS no longer panics on startup. The OCSP-staple and ACME-renewal
background tasks were spawned before the proxy runtime existed, so any HTTPS
listener with a manual cert (tls_cert_file/tls_key_file) or enabled ACME
crashed the process on boot ("there is no reactor running"). The tasks now
spawn on a runtime that is always available. - HTTP/2 is now negotiated over TLS. No TLS listener advertised
h2in ALPN,
so every HTTPS connection fell back to HTTP/1.1. The manual-cert, ACME, and
mTLS listeners now enable h2; clients that do not offer it still get HTTP/1.1.
[1.3.0] - 2026-06-25
Third minor release on the Rust v1.x line. Two headlines: dynamic key
management with an open-source mesh for clustering, and a wave of
state-of-the-art AI-gateway capabilities. No config-breaking changes;
existing sb.yml files compile unchanged, and every new field is
default-off.
Added
- Dynamic key management. Inbound virtual keys are a live, governed
resource: mint, list, rotate, and revoke them at runtime through an admin
API under/admin/keys, with no reload. Keys are hashed at rest with
HMAC-SHA256 and a server pepper, and a revoke takes effect on the next
request. Upstream provider credentials are encrypted at rest with an
AES-256-GCM envelope or held as a vault reference. Per-key policy travels
with the key: model and provider allow/deny, rate and token limits, token
and USD budgets, expiry, required PII redaction, principal selectors, a
pinned model, injected tools, and an injection-scan bypass. Pluggable
stores: embedded (redb), Redis, or a secrets manager. OIDC and JWT claims
can map to a key. Newkey_management:config block. (#542, #543) - Open-source mesh clustering. The mesh layer (SWIM gossip, a
consistent-hash distributed cache) is now Apache-2.0 in this repository.
Settingcache.tier: meshkeeps the key plane coherent across a replica
fleet: a key minted on one replica is usable on any, and a revocation on
one denies on the rest, with no external control plane in the path. Per-key
spend and rate counters remain node-local; cluster-wide budget enforcement
uses a shared backend. (#542) - State-of-the-art AI-gateway differentiation. A verifiable, hash-chained
and optionally Ed25519-signed usage ledger; a single sandboxed CEL policy
plane over guardrails, budgets, routing, and principal; a guardrail mesh
that fuses verdicts on a quorum with a verdict cache; outcome-aware routing
by realized cost-per-success; predictive budgets that warn, then downgrade,
then block; and LLM-aware resilience: per-error retry, context-window
compression, hedged and raced dispatch, and content-policy fallback to a
more permissive provider. (#538, #539, #540, #541) - LiteLLM drop-in. A
config import-litellmtranslator, model groups, and
usage-sink plus budget foundations for moving a LiteLLM proxy over. (#537) - Model-based routing with a failover metric and a refreshed model-id
catalog. (#536) - VHS cassettes for the AI gateway and the example configs. (#534)
Changed
- The mesh wire encoding moved off the unmaintained
bincodecrate to
postcard. - The README and docs now lead with the two-way framing: SBproxy governs the
AI you call and the AI that calls you.
[1.2.0] - 2026-06-24
Second minor release on the Rust v1.x line. Headline: local ONNX
inference for the embedding semantic cache and the prompt-injection
classifier, a standalone OpenAI-compatible embedding source, a
best-of-class OpenTelemetry story for the AI gateway, and the move to
Apache 2.0. No config-breaking changes; existing sb.yml files compile
unchanged.
Added
- Local ONNX inference for the semantic cache. The embedding
semantic cache can vectorize prompts on-box, with no per-call API cost
and no prompt egress.source: sidecarruns the embedder in the
supervised classifier sidecar;source: inprocessloads an ONNX model
(all-MiniLM-L6-v2 by default) into the proxy behind an explicit opt-in
and amax_model_bytesguard. Prompt-injection v2 gains first-class
ONNX detectors (detector: sidecar,detector: inprocess) next to the
zero-dependency heuristic default. See
docs/local-inference.md. - OpenAI-compatible embedding source (
source: openai). Vectorize
prompts through any standalone OpenAI-compatible/v1/embeddings
endpoint, decoupled from the origin's chat providers: point it at
another sbproxy that fronts an embedding model, at OpenRouter, or at a
hosted provider. Auth defaults toAuthorization: Bearer; set
auth_header/auth_prefixforapi-key/x-api-keyendpoints, or
carry the credential in arbitrary extraheaders. - Best-of-class OpenTelemetry for the AI gateway. AI spans now carry
derived USD cost (and a first-class cost metric), map failures
(guardrail, provider 429/5xx, content filter) to span status ERROR with
anerror.type, and emit capture-gated, redacted prompt and completion
content as OpenInference / OTel gen_ai span events. A pinned GenAI
semantic-convention conformance test guards against attribute drift.
The reference stack adds Arize Phoenix and Langfuse with provisioned
dashboards, plus cost-aware (ParentBased + TraceIdRatio) trace
sampling. docs/observability.md gains a
verified backend matrix. - Per-credential, multi-tenant, multi-model AI value tracking in the
reporting surface. - GCP Secret Manager vault backend (
gcpsm://), joining HashiCorp
Vault (vault://) and AWS Secrets Manager (awssm://). - Configurable retry on upstream response statuses.
- Web Bot Auth key IDs now feed the agent identity proof.
Changed
- SBproxy OSS is now licensed Apache 2.0. The previous Business
Source License field-of-use restriction is dropped; the project is free
for any use, including production and commercial, with no field-of-use
limit. - Vault references moved to per-provider schemes. The scheme now
selects the backend (vault://HashiCorp,awssm://AWS,gcpsm://
GCP) rather than avault://<alias>umbrella form. The legacy form
still resolves during a deprecation window and logs a one-time warning. - HTTP/3 (QUIC) is temporarily disabled until native support lands in
the underlying proxy engine. Existing config still parses, but no
HTTP/3 listener starts. - The admin playground chat route is gated by default.
Fixed
- Credential selectors are enforced consistently across request paths,
and the AI preference script context is exposed to request scripts.
[1.1.0] - 2026-06-06
First minor release on the Rust v1.x line. This release carries
breaking changes to the MCP tool-access policy (now closed-by-default
and principal-aware); read the Breaking section and
docs/migration-mcp-rbac.md before upgrading. It also ships 66 native
AI providers behind one OpenAI-compatible API.
Breaking
-
MCP default-deny:
ToolAccessPolicyflipped from
open-by-default to closed-by-default. An unknown caller (no
matching ACL rule) is denied every tool. An emptyallowed: []
list under an ACL rule means "deny all", not "allow all".
Operators who want the legacy behaviour adddefault_allow: true
on the origin's MCP action. The legacykey_permissions: { key: [tools] }
shape is gone; rewrite to the principal-awaretool_access[]
selector list. Seedocs/migration-mcp-rbac.md. -
MCP principal-aware ACL:
ToolAccessPolicynow
carriestool_access[]rules withprincipals[]selectors
(virtual_key,sub,team,project,user,role,
tenant_id) plus anallowed[]tool list. The legacy
key_permissions: HashMap<String, Vec<String>>map is removed
along withToolAccessPolicy::is_tool_allowed(key, tool); the new
surface ispolicy.check(&principal, tool) -> ToolAccessDecision
andpolicy.filter_tools(&principal, &tools).tools/listnow
filters by RBAC against the inbound principal (the legacy schema
leaked tool names throughtools/listeven when the gate would
deny the matchingtools/call). A newtool_quotas[]table
enforces per-tool sliding-window quotas keyed on
(tenant_id, principal_id, tool_name). See
docs/migration-mcp-rbac.md.
Added
-
66 native AI providers behind one OpenAI-compatible API. The
embeddedai_providers.ymlregistry ships 66 providers (up from 43),
adding Hugging Face Inference, GitHub Models, Vercel AI Gateway,
Nebius, Baseten, Lambda, FriendliAI, Scaleway, Nscale, DigitalOcean
Gradient, OVHcloud, Inference.net, kluster.ai, OpenPipe, Writer,
Upstage, Aleph Alpha, MiniMax, Volcengine Ark (Doubao), Tencent
Hunyuan, Baidu Qianfan (ERNIE), StepFun, and Mixedbread. The catalog
is plain YAML and operator-extensible at runtime via
proxy.ai_providers_file; themodelfield passes through to the
upstream, so any model a provider serves is reachable without
per-model config. The "200+ models" reach is native (bring your own
keys); OpenRouter is one provider among the 66, not a dependency. See
docs/providers.md#extending-the-provider-catalog. -
Session ledger from live MCP traffic. A new top-level
session_ledger:block makes SBproxy emit the canonical
session-ledger-v1run record (shared with mcptest) from its
tools/callpath: oneheaderper session, then onetool_call
record per call carryingsession_id, a zero-basedhop_index, the
bare tool name and server, redactedparams/result, an error
flag, and the round-tripduration_ms.sink: logging(default)
emits each record as asession_ledgertracing line;sink: file
with apath:appends NDJSON. Off unlessenabled: true; when off
the tool-call path pays only a single atomic load. Payloads are
redacted with the same secret-stripping the access log uses. See
docs/mcp.mdandexamples/mcp-federation/sb.yml. -
Structured-log schema v2 (
SCHEMA_VERSION = "2"). Three changes
land together so downstream tooling can read them in one swing:
optionalsession_idanduser_idtop-level fields parallel the
RequestEventenvelope (cross-surface JOIN no longer relies on
request_idalone); the field-key redaction marker is normalised
to[REDACTED:<NAME>]everywhere (was<redacted:name>in v1) so
the schema-v1 layer matches the existing PII-rule replacement
shape; the schema bump is additive on the field set (a v1 reader
parsing a v2 line keeps working because every new field is
skip_serializing_if = Option::is_none). Marker normalisation is
a string change; downstream tooling that greps for the old
<redacted:...>form must update. -
Phase-timing breakdown on the access log + new
sbproxy_phase_duration_secondsPrometheus histogram. The
access log carriedlatency_msend to end and that was it; an
operator looking at a slow request could not tell from the log
whether the time went to the auth provider, the upstream, or a
response transform. Three new optional fields land on every
AccessLogEntry:auth_ms(request_start → auth provider
returned),upstream_ttfb_ms(request_start → first upstream
response byte),response_filter_ms(first upstream byte → end
ofresponse_filter). All three areOption<f64>and
serde-skipwhen None, so origins that short-circuit (cache
hit, auth deny) keep compact lines. The same observations also
feed a newsbproxy_phase_duration_seconds{phase, origin}
histogram with buckets identical to
sbproxy_request_duration_secondsfor cross-cut dashboards. See
docs/access-log.mdanddocs/metrics-stability.md. -
Nine standard HTTP fields on the access log:
host,query,
protocol,scheme,user_agent,referer,upstream_status,
response_content_type,response_content_encoding. The log
was missing the canonical fields most HTTP access-log consumers
expect (Apache, NGINX, Envoy, the cookie-cutter ELK pipeline).
hostis the client-supplied Host header (distinct from
origin, the matched virtual-host pattern);upstream_status
is the upstream's response code when the proxy rewrote the
status the client sees. All nine areOption,serde-skipwhen
not applicable. Promoted from the generic header allowlist
because nearly every analytics consumer wants them. See
docs/access-log.md. -
Opt-in OpenTelemetry metrics mirror alongside the canonical
Prometheus surface. Newtelemetry.export_metrics: true
(withtelemetry.metrics_interval_secscadence, default 30s)
installs an OTelMeterProviderthat ships observations to the
same OTLP collector the trace pipeline targets. The first two
mirrored instruments aresbproxy.phase.durationand
sbproxy.request.duration; record-paths fall back to OTel's
global no-op meter when the export is off, so operators pay
nothing for the mirror unless they opt in. The Prometheus
surface remains canonical; this is for operators who already
aggregate via Mimir / Datadog / Honeycomb and want to skip the
Prometheus scrape. -
OIDC Relying-Party stack shipped end to end.
/oidc/callback(auth-code + PKCE + sealed session cookie)
plus the helpers + config wiring for
/.well-known/openid-configurationdiscovery, refresh-token
rotation, RP-initiated logout at/oidc/logout, userinfo →
X-Auth-*trust headers, an optional server-side session store
(in-memory + KV-backed redb/file/Redis) for targeted revocation.
Seedocs/configuration.md§ OIDC auth. -
OpenAI Apps SDK / MCP Apps (SEP-1865) compatibility.
Gateway-side_meta.mcpAppspassthrough for tool definitions,
params.audit.causeplumbing ontools/call, and a typed
validator set (apps.template_declared,apps.iframe_sandbox,
apps.csp_present,apps.cache_metadata) usable by sbproxy,
the enterprise extension, and any CI gate over the
sbproxy-pluginsurface. -
Web Bot Auth full conformance, publish + sign sides.
SBproxy now publishes its own JWKS-shaped
directory at/.well-known/http-message-signatures-directory
and a Signature Agent Card at
/.well-known/web-bot-auth/agent-card(opt in via
web_bot_auth_publishper origin). New
sbproxy-middleware::signatures::MessageSignatureSigner
primitive signs outbound requests per RFC 9421, round-trips
through the existing verifier. Seedocs/web-bot-auth.mdand
examples/web-bot-auth-publish/. -
Three previously-undocumented OSS policies now have docs +
runnable examples:object_authz(BOLA + BFLA with
enumeration detection),content_digest(RFC 9530 request-body
verification),agent_budget(per-agent semantic rate limit).
Seedocs/object-authz.md,docs/content-digest.md,
docs/agent-budget.md. -
Discoverable FAQ.
docs/faq.mdcovers install, common
401 causes, OIDC minimal config, log levels, OSS-vs-enterprise
scope, and pointers into the rest ofdocs/. Wired into
docs/README.mdunder "Getting started". -
Explicit SIGINT/SIGTERM handling with a structured shutdown
event and a 30s default drain budget. Pingora's
Server::run_foreveralready trapped SIGTERM and SIGINT, but
the proxy emitted no operator-facing log line on receipt, so a
pod eviction ordocker stoplooked the same as a crash in the
log stream. This change subscribes to Pingora's execution-phase
broadcast and emitsshutdown_signal_received,
shutdown_grace_period, andshutdown_completetracing events
with the resolved grace budget. The Kubernetes operator
(sbproxy-k8s-operator) now installs the same SIGINT/SIGTERM
handlers viatokio::signal::ctrl_cand
tokio::signal::unix::signal(SignalKind::terminate()); before
this change the operator relied on the orchestrator SIGKILL at
terminationGracePeriodSeconds. The drain budget is the new
SBPROXY_SHUTDOWN_GRACE_MSenv var (or--shutdown-grace-ms
CLI flag) which defaults to 30000ms, matching Kubernetes'
defaultterminationGracePeriodSeconds. The legacy
SB_GRACE_TIME/--grace-time(seconds) still works and
takes precedence when explicitly set; an unset legacy var lets
the new 30s default apply. Operator exits 0 on a clean drain,
1 when the grace window is exceeded, so the orchestrator can
alert. Documented indocs/manual.md§3 and
docs/kubernetes.md§Graceful shutdown. -
Idempotency middleware now engages on AI gateway origins
(action: ai_proxy). Before this change, the
RFC 8594 middleware only ran on general HTTP origins
(action: proxy). AI customers usingIdempotency-Key
headers for Stripe-style retries were double-billed by the
upstream provider because the proxy did not replay from cache.
The fix engages the same primitive inhandle_ai_proxyafter
the request body is buffered (the AI gateway already buffers
for the JSON parser, model router, and guardrails) and before
the upstream call. On a cache hit the gateway writes the
cached(status, headers, body)triple directly to the client
withx-sbproxy-idempotency: HITand never contacts the
provider. On a body conflict the gateway returns 409
ledger.idempotency_conflictper the RFC. On a miss the
gateway forwards, then records the post-translation OpenAI-shape
bytes the client actually saw so retries replay byte-identical.
Reuses the same per-request and pool caps shipped on
CompiledIdempotency:max_request_body_bytes,
max_response_body_bytes,max_concurrent_buffers. The four
skip markers (SKIPPED-OVERSIZE-REQUEST,SKIPPED-POOL-FULL,
SKIPPED-OVERSIZE-RESPONSE,SKIPPED-MULTIPART) stamp on the
outgoing response so operators see graceful degradation in
dashboards. Multipart bodies (audio transcription, image edit /
variation, file upload) skip caching withSKIPPED-MULTIPART
because the cache primitive stores raw bytes and multipart
boundaries may be regenerated by clients on retry. Streaming
(SSE) chat completion responses abandon the cache record on
oversize because framing-aware capture is out of scope for v1. -
proxy_statusandproblem_detailsnow cover upstream
failures. Before this change,proxy_status.enabled: true
stamped theProxy-Statusheader on proxy-generated errors
(auth deny, policy deny, default 404) but not on upstream
failures routed through Pingora'sfail_to_proxypath (connect
refused, connect timeout, TLS handshake error, mid-stream
connection loss). The fix wires both blocks into the
upstream-failure path so dashboards consumingProxy-Statussee
consistent coverage across error sources. The status code +
RFC 9209errortoken derive from the PingoraErrorTypevia
a newmap_upstream_failuretranslator: 504 +
connection_timeoutforConnectTimedout/
ReadTimedout; 502 +connection_refusedforConnectRefused;
502 +tls_protocol_errorfor TLS errors; 502 +
connection_terminatedfor mid-stream loss; 502 +
http_request_erroras the catch-all. When
problem_details.enabled: truethe body is now rendered as
application/problem+jsonfor upstream failures too, with the
RFC 9209 error token in thedetailfield so both signals share
the same vocabulary. -
Idempotency cache check moved to
request_filter. Before this
change, the cache lookup ran inrequest_body_filter, after
Pingora had already opened the upstream TCP connection. On a cache
hit the upstream observed one aborted partial request before the
proxy served the cached response to the client. The check now runs
before Pingora's upstream-peer phase: cache hits and body
conflicts write the response from insiderequest_filterand
returnOk(true), so the upstream is never contacted at all. On
cache miss the proxy buffers the body (bounded by
max_request_body_bytesfrom PR #139), then re-injects it via
request_body_filterat end-of-stream so Pingora's normal upstream
forwarding picks it up. Existing e2e tests now assert the
upstream-not-contacted invariant; the previous "may observe one
aborted partial request" caveat has been removed from
docs/configuration.mdand the example README. -
Idempotency middleware: per-request and pool caps. Three new
fields on theidempotency:block bound memory usage and let the
middleware gracefully degrade under pressure rather than buffering
unbounded bodies.max_request_body_bytes(default 1 MiB) caps
the per-request buffer; bodies above the cap skip caching with
x-sbproxy-idempotency: SKIPPED-OVERSIZE-REQUESTstamped on the
response.max_response_body_bytes(default 1 MiB) caps the
per-response cache buffer; responses above the cap stream through
uncached.max_concurrent_buffers(default 256) is a per-origin
pool over concurrent buffered requests; pool exhaustion skips the
cache withx-sbproxy-idempotency: SKIPPED-POOL-FULL. Worst-case
memory is bounded atmax_concurrent_buffers * max_request_body_bytes
per origin. -
RFC 8594 idempotency middleware (
idempotency:). Per-origin
block that engages on POST / PUT / PATCH (configurable via
methods:) when anIdempotency-Keyheader is present. The
middleware sits ahead of policies in the handler chain, hashes the
request body, and short-circuits the three branches per the RFC:
cache hits replay the cached(status, headers, body)verbatim
withx-sbproxy-idempotency: HIT; conflicts (same key, different
body) return 409 with theledger.idempotency_conflictJSON body;
misses forward to the upstream and capture the response for the
next retry. Workspace-isolated keys prevent cross-tenant
collisions. Memory backend (default) is per-origin and per-replica;
backend: redisbinds toproxy.l2_storeat config-compile time
for cluster-wide replay. Cached replays do not consume rate-limit
slots. Documented indocs/configuration.mdand demonstrated by
examples/idempotency/. Known v1 limitation: the cache check
fires inrequest_body_filter, after Pingora has already opened
the upstream connection. On a cache hit the upstream observes one
aborted partial handshake before the proxy serves the cached
response to the client; future work moves the check earlier so the
upstream never sees the replay. -
RFC 9457 problem-details default renderer (
problem_details:).
New per-origin block that opts in toapplication/problem+jsonfor
proxy-generated errors (authentication denials, policy denials,
default 404) that are not matched by an authorederror_pages
entry. The two blocks compose: per-status custom pages still win
when authored;problem_detailscatches everything else with a
structuredtype/title/status/detail/instance
body.type_base_uriproduces stable per-statustypeURIs;
include_detail: falsesuppresses the internal error string.
Documented indocs/configuration.mdand demonstrated by
examples/problem-details/. -
Typed
error_pagesconfig. The opaque
error_pages: Option<serde_json::Value>field is now typed as
Option<Vec<ErrorPageEntry>>. Public typesErrorPageEntry,
StatusSpec, andProblemDetailsConfiglive insbproxy-config.
The authored YAML shape is unchanged: every existing
error_pages:list keeps parsing, including thestatus:single-
int /[status]list shorthand andtemplate: truesubstitution.
The OpenAPI emitter now walks typed entries to populate
per-statusresponseskeys (the previous code inspected the
field as an object and silently produced no entries; this is a
bug fix on top of the migration). -
AI gateway Realtime WebSocket dispatch (Phase 7, Option C).
GET /v1/realtimerequests withUpgrade: websocketagainst an
ai_proxyorigin are now dispatched through the AI gateway
pipeline:- Pre-upgrade gating runs the same surface classification, 501
capability check (only providers in
provider_supports_realtimeare eligible; today: OpenAI),
per-surface rate limit, and provider selection as the rest of
the AI surface set. - After the gating passes, Pingora forwards bytes between
client and provider transparently through the upgraded
connection. The dispatcher does not terminate the WebSocket;
per-frame guardrails and frame-exact audio metering are
reserved for a future enterprise terminate-and-relay path so
every AI gateway feature added tohandle_actioncontinues
to apply to realtime through one shared code path. sbproxy_ai_realtime_sessions_active(gauge),
sbproxy_ai_realtime_session_duration_seconds(histogram),
sbproxy_ai_realtime_audio_seconds_total(counter), and
sbproxy_ai_realtime_frames_forwarded_total(counter) are
registered. The OSS dispatch ticks the gauge on session open
and observes the duration histogram on close. Documented in
docs/metrics-stability.md.- At session close,
loggingemits a session-end
AiBillingEventwithAudioSeconds { seconds }valued at
the wall-clock session duration so realtime usage appears on
the standard billing-event bus alongside chat/image/audio. RealtimeSessionTracker(lock-free atomic counters) and
audio_seconds_from_frame(bytes, sample_rate, channels)ship
insbproxy-ai::realtimefor the eventual terminate-and-relay
path to consume.docs/ai-gateway.mddocuments the new dispatch path with a
YAML example and the per-surface rate-limit knob.
- Pre-upgrade gating runs the same surface classification, 501
-
AI gateway OpenAI surface dispatch (Option A). The
ai_proxy
action now routes every OpenAI-compatible surface through a
single classifier with per-surface observability and gating:- New
AiSurfaceenum +classify_surface(method, path)cover
chat completions, models, embeddings, assistants and threads
(full v2 surface), batches, fine-tuning, files, realtime,
image generation/edits/variations, audio transcription/speech,
moderations, and reranking. Marked#[non_exhaustive]so
future variants don't break downstream pattern matches. - Method coverage extended past GET/POST: DELETE, PUT, PATCH,
HEAD, and OPTIONS dispatch throughAiClient::forward_with_method
without engaging the JSON body-parse pipeline. - Multipart bodies (image edits/variations, audio transcription,
file uploads) byte-forward viaAiClient::forward_byteswith
the inboundContent-Typepreserved. Previously these surfaces
returned a 400 "invalid JSON body" from the chat-path body parse. - Provider capability matrix in
api_routes.rscorrected:
Anthropic no longer claims audio/reranking/moderations support,
Gemini no longer claims moderations. A new
provider_supports_surfacematrix gates non-universal surfaces
with 501 Not Implemented when no configured provider
supports the surface. - Per-surface observability: new
sbproxy_ai_surface_requests_total{surface, method}counter and
sbproxy_ai_surface_request_duration_seconds{surface, method}
histogram. Sibling of the existing per-provider metrics so
dashboards can pivot between surface and provider views.
Documented indocs/metrics-stability.md. - Per-surface input guardrails: image generation, audio speech,
reranking, and moderations bodies now have their input field
(prompt,input,query,input) extracted and run through
the same guardrail pipeline as chat-stylemessages. - Per-surface rate limits: new
per_surface_rate_limitsfield
on the AI handler config, keyed by surface label. 429 fires
before any upstream call when the cap is hit. - Surface-aware billing event: new
AiBillingEventcarrying
AiUsagewithTokens,Images { count, resolution },
AudioSeconds,Characters,RerankUnits, andPerCall
variants. Every dispatched request emits exactly one event.
Image generation, audio speech, and reranking emit real cost
via per-surface pricing tables (lookup_image_price,
lookup_audio_speech_price,lookup_rerank_price,
lookup_audio_transcription_price).docs/ai-gateway.md
documents the new surface, methods, guardrails, and rate-limit
knobs.
- New
-
Policy verdict audit bus + Plugin dispatch.
Wires the previously-deadPolicy::Pluginarm inserver.rsto
call the trait'senforce(), folds the returnedPolicyDecision
into the existing chain reducer, and emits a
PolicyVerdictEventfor every decision on a bounded
tokio::sync::mpscaudit bus per
docs/adr-policy-audit-binding.md. The OSS substrate ships an
in-memory drain stub; enterprise replaces the consumer with a
NATS-backed audit-chain subscriber. Multi-policy resolution
rules fromdocs/adr-policy-verdict-shape.mdare implemented at
the chain level: any Deny wins, the first Confirm wins over
AllowWithHeaders, AllowWithHeaders accumulate, otherwise Allow.
Confirmin OSS routes through the existing AllowWithHeaders
mechanism withX-Policy-Confirm: <reason>stamped on the
response; anexpires_atalready in the past synthesises a 410
and an SSRF-blockedwebhook_urlsynthesises a 502 at decision
time. New metrics:
sbproxy_policy_audit_events_total{verdict, surface, policy_id},
sbproxy_policy_audit_events_dropped_total{tenant},
sbproxy_policy_decision_duration_seconds{surface}. New Grafana
dashboardsbproxy-policy-verdictscovers the surface.
([crates/sbproxy-observe/src/events.rs],
[crates/sbproxy-observe/src/metrics.rs],
[crates/sbproxy-core/src/policy_bus.rs],
[crates/sbproxy-core/src/policy_dispatch.rs],
[crates/sbproxy-core/src/server.rs],
[crates/sbproxy-plugin/src/traits.rs],
[dashboards/grafana/sbproxy-policy-verdicts.json]) -
Synthetic-transaction
/readyzprobe. Optional
background driver that fires an in-process request through the
compiled handler chain on a fixed cadence and reports the verdict as
asynthetic_pipelinecomponent on/readyz. Disabled by default;
opt in viaproxy.synthetic_probe.enabled: trueand define an origin
for the configured sentinel hostname (default__synthetic.local)
pointing at a non-network action (static,mock,echo,noop).
Failures bump the new
sbproxy_synthetic_probe_failures_total{reason}counter so they do
not pollute real-traffic error metrics.
([crates/sbproxy-config/src/types.rs],
[crates/sbproxy-core/src/synthetic.rs],
[crates/sbproxy-observe/src/synthetic.rs],
[crates/sbproxy-observe/src/metrics.rs],
[e2e/tests/synthetic_probe.rs]) -
GET /admin/driftconfig drift endpoint. Returns
whether the on-disk config file has diverged from what the running
proxy has loaded, without triggering a reload. Compares a
content-hash baseline captured at startup (and refreshed on every
/admin/reload) against a fresh hash of the current file. K8s
operators and dashboards scrape this so they can flag an edited
config that has not been hot-reloaded yet. Documented in
docs/configuration.md§ Admin fields.
([crates/sbproxy-core/src/admin.rs],
[crates/sbproxy-core/src/server.rs],
[docs/configuration.md]) -
Deterministic clock-skew testing hooks.
ClockSkewMonitornow
accepts an injected clock source for tests while production continues
to use the system clock.
([crates/sbproxy-observe/src/clock_skew.rs]) -
Operator runbook hooks and fast-track ADR template. Added a
dashboard-oriented operator runbook, linked all Grafana panels to the
relevant triage sections, and added a fast-track ADR amendment
template plus OSS threat-model refresh checklist.
([docs/operator-runbook.md], [docs/adr-fast-track-amendment.md],
[docs/threat-model.md], [dashboards/grafana/]) -
Live reverse-DNS resolver for agent verification.
SystemResolver
now useshickory-resolverfor PTR and forward-confirmation lookups,
replacing the previous typed PTR stub.
([crates/sbproxy-security/src/agent_verify.rs]) -
Multi-window SLO burn-rate replay harness.
sbproxy-observe
now includes a burn-rate evaluator andAlertSnapshotreplay helper
for substrate availability and latency alert taxonomy tests.
([crates/sbproxy-observe/src/alerting/burn_rate.rs],
[e2e/tests/slo_burn_rate.rs]) -
Vault-style quote-token seed references.
ai_crawl_control.quote_token.secret_ref
now acceptssecret:references resolved throughsbproxy-vault
with the existing environment fallback, in addition to the older
secret_ref.envand inlineseed_hexpaths.
([crates/sbproxy-modules/src/policy/ai_crawl.rs]) -
Operator first-24-hours quickstart. Added a concise
docs/quickstart-operator.mdcovering deploy,/readyz, metrics,
Grafana, logs, and rollback, linked from the README and Kubernetes
docs.
([docs/quickstart-operator.md]) -
Hostname cardinality override for metrics.
proxy.metrics.cardinality.hostname_cap
can lower thehostnamelabel budget independently from the default
per-label cap, enabling deterministic overflow tests and tighter
multi-tenant Prometheus budgets.
([crates/sbproxy-config/src/types.rs],
[crates/sbproxy-observe/src/cardinality.rs]) -
release-fastbuild profile for CI images. Docker-based CI and
local kind smoke-test builds can now useCARGO_PROFILE=release-fast
to skip fat LTO and use more codegen units, cutting link memory/time
while leaving production release artifacts on the existingrelease
profile.
([Cargo.toml], [Dockerfile.ci], [Dockerfile.cloudbuild]) -
Reproducible build probe workflow. CI now has an informational
double-build lane that builds the release binary twice on independent
GitHub-hosted runners, uploads each binary and SHA-256, and publishes
a comparison report without yet treating non-identical output as a
failure.
([.github/workflows/reproducible-build.yml], [SUPPLY-CHAIN.md]) -
Phase 2: CEL
features[...]namespace. Per-request
flags parsed from thex-sb-flagsheader and?_sb.<key>query
prefix are now exposed to CEL expressions. Built-in flags surface
as bools (features.debug,features.trace,
features["no-cache"],features.any_set); free-formk=vextras
surface as strings (features["env"]). Wired into the rate-limit
CEL evaluator andExpressionPolicy::evaluate_with_views.
([crates/sbproxy-extension/src/cel/context.rs]) -
SB_WORKER_THREADSenv var. Positive integer overrides the
auto-detected Pingora worker thread count
(std::thread::available_parallelism()). Useful for benchmarking
with a fixed worker count or capping the pool below a cgroup quota.
([crates/sbproxy-core/src/server.rs]) -
/live,/livez,/ready,/healthz, and rich/health
admin endpoints.
/livezreturns{"alive":true}on every call and never 503s, so
K8s liveness probes don't trip on transient readiness failures.
/liveis a bare alias./readyis an alias for/readyz.
/healthzstays a fixed liveness body, while/healthnow returns
version, build hash, timestamp, uptime, and readiness checks for
dashboards / SIEM ingestion. Existing/readyzbehavior unchanged.
([crates/sbproxy-observe/src/health.rs],
[crates/sbproxy-core/src/admin.rs]) -
--request-log-levelandSB_REQUEST_LOG_LEVEL. Operators can
now tune request/access logging independently from application logs.
The setting appends anaccess_log=<level>target directive to the
effectivetracing-subscriberfilter while preserving the existing
per-targetRUST_LOGescape hatch.
([crates/sbproxy/src/main.rs]) -
Access-log forced emission and file output.
access_lognow
supportsslow_request_threshold_msandalways_log_errorsso slow
requests and 5xxs bypass sampling after status/method filters match.
It also supportsoutput: { type: file, path, max_size_mb, max_backups, compress }for direct JSON-line access-log files with
size-based rotation and optional gzip compression of rotated files.
([crates/sbproxy-config/src/types.rs],
[crates/sbproxy-core/src/server.rs],
[crates/sbproxy-observe/src/access_log.rs]) -
OCSP stapling for the manual fallback cert.
OcspStapler
(which previously existed but was unwired) now does an immediate
fetch on startup, refreshes every 12 hours, and pushes the bytes
intoCertResolver::update_fallback_ocspso subsequent rustls
handshakes staple the response on the wire. No-op when no manual
cert is configured or when the cert lacks an AIA extension.
([crates/sbproxy-tls/src/ocsp.rs],
[crates/sbproxy-tls/src/cert_resolver.rs]) -
Readiness synthetic probe primitive.
sbproxy-observenow ships a
SyntheticProbetype so startup or test wiring can register an
in-process readiness probe that exercises a caller-provided path and
reports through the same/readyzcomponent model as built-in probes.
([crates/sbproxy-observe/src/health.rs])
Removed
sbproxy_ai::IdempotencyCache. The OSS AI gateway never wired
this cache; it was publicly re-exported but had zero callers in the
workspace. The newidempotency:block on general HTTP origins
(above) supersedes it. AI gateway integration is a follow-up tracked
indocs/missing.md. Plugin authors that imported the removed
type can switch to
sbproxy_middleware::idempotency::{IdempotencyCache, InMemoryIdempotencyCache, KvIdempotencyCache}which carries the
richer surface (workspace isolation, body-hash conflict detection,
conflict body builder).
Changed
-
mTLS now wired on the ACME path. Previously, an operator who
configuredmtls:alongsideacme:got plain TLS until they
noticed clients reaching the upstream without the expected cert
headers. The ACME branch now mirrors the manual-cert branch:
buildsTlsSettingswith the configuredClientCertVerifierand
falls back to plain TLS only when mTLS setup itself fails.
([crates/sbproxy-core/src/server.rs]) -
Examples and Kubernetes smoke checks are local-only. The
Docker-backed examples smoke lane and kind-based Kubernetes operator
smoke lane no longer run automatically on pull requests. They remain
available asmake examples-smokeandmake k8s-operator-smokefor
explicit local / release validation.
([Makefile], [docs/kubernetes.md]) -
Reload drain state is now one coherent atomic snapshot. The
drain flag and active request count are packed into oneAtomicU64,
sois_draining()no longer combines two independent relaxed loads.
Added loom coverage for the last-request-finish interleaving.
([crates/sbproxy-core/src/reload.rs]) -
Optional readiness dependencies no longer fail
/readyzby
default. The default admin health registry now registers absent
ledger and bot-auth-directory probes asnot_configured, matching the
existing future-wave stubs and keeping/readyzgreen when those
optional services are not wired in a deployment.
([crates/sbproxy-observe/src/health.rs],
[crates/sbproxy-core/src/admin.rs]) -
docs/manual.mdrewrites matching what actually ships:- §6 Health checks:
/livez,/readyz,/healthz, and rich
/healthsemantics, replacing the old per-endpoint URL fork
diagram and stale/healthalias wording. - §10 Feature flags: CEL accessor table, kill-switch note, and
a "planned, not yet wired" note for Lua / JS / WASM features
namespaces and workspace-level pub/sub flags. - §3 CPU detection: documents the new
SB_WORKER_THREADSknob. - §13 env-var table: adds
SB_WORKER_THREADSand
SB_DISABLE_SB_FLAGS; later updates add
SB_REQUEST_LOG_LEVELand access-log file/forced-emit examples.
- §6 Health checks:
Fixed
-
CAP
subbinding only fires for a genuinely resolved agent. The
CAP verifier binds a token'ssubto the request's resolved agent id
(rejecting a mismatch with403). Because the agent-class resolver is
installed with the built-in catalog by default and always stamps
some id (falling through to thehumansentinel when no signal
matches), the binding would have rejected every CAP token whosesub
was not literally"human", even on origins that never configured
agent classes. The binding now skips the resolver's fallback /human
verdict and engages only when the resolver actually identified an
agent, so an unauthenticated caller falls through to the normal CAP
validation path. Setcap.require_agent_binding: trueto fail closed
when no agent is resolved. -
Virtual-key model allow/block lists are now enforced. A virtual
key (orai_providercredential) withmodels.allow/models.block
declared its scope but the AI dispatch path never checked it, so a key
confined to a subset of the gateway's models could still call any
model the gateway served. The matched key's allow/block lists are now
enforced against the effective model (after anyroute_to_model
rewrite): a request for a disallowed model is rejected with403
before any upstream call, the block-list taking precedence over the
allow-list. Keys with nomodels.alloware unaffected. See
examples/ai-virtual-keys/. -
Licensing-projection wire formats now match the canonical specs [BREAKING]. Two projection emitters were producing
document shapes that didn't match their cited specifications.
/licenses.xmlpreviously declared the namespace
https://rsl.ai/spec/1.0and emitted a flat
<rsl><license urn=...>...</license></rsl>document. The canonical
RSL Collective spec at https://rslstandard.org/rsl uses the
namespacehttps://rslstandard.org/rsland a nested
<rsl><content url="..."><license>...</license></content></rsl>
shape; the<content>urlattribute is the canonical wildcard
https://<hostname>/*for the origin-wide license./.well-known/tdmrep.json
previously wrapped its policies in a{"version", "generated", "policies": [...]}
envelope; the W3C TDMRep CG-FINAL spec mandates a bare JSON array
at the document root withlocation,tdm-reservation
(integer 0 or 1), andtdm-policy(URL of the policy document)
fields per entry. Both emitters now produce the canonical shapes.
Operators consuming/licenses.xmlor/.well-known/tdmrep.json
programmatically must update their parsers to the new shapes; the
in-process JSON envelope and the response middleware that stamps
TDM-Reservation: 1and the URN-bearinglicensefield are
unaffected. Conformance is asserted by the active structure-shape
tests; the earlier schema-validation tests were removed because
neither standard publishes a machine-readable schema to validate
against (RSL 1.0 is prose-only; W3C TDMRep ships no JSON Schema).
([crates/sbproxy-modules/src/projections/licenses.rs],
[crates/sbproxy-modules/src/projections/tdmrep.rs],
[e2e/tests/rsl_licenses_projection_e2e.rs],
[e2e/tests/tdmrep_projection_e2e.rs]) -
Build under prometheus 0.14 type inference. Sites in
sbproxy-observe::metricsandsbproxy-core::serverthat passed
heterogeneous&[&String, &str]arrays to
prometheus::with_label_valuesno longer compile on prometheus
0.14 because Rust unifies the array element type to&Stringand
rejects bare&strliterals. Coerced all such call sites to
uniform&[&str]via.as_str()so the workspace builds clean
again. No behavioural change.
([crates/sbproxy-observe/src/metrics.rs],
[crates/sbproxy-core/src/server.rs]) -
WASM extension docs corrected.
CLAUDE.mdpreviously labeled the
WASM surface as "WASM stub" while marketing docs claimed
production-grade support; the runtime is real
(wasmtime+ WASI preview-1 with sandboxed memory and CPU caps,
stderr capture, no FS or network).llms.txtalso incorrectly
claimed "WASI networking with host allowlist" butallowed_hostsis
parsed-but-inert until WASI sockets land. CLAUDE.md and llms.txt now
match the shipped surface.
([CLAUDE.md], [llms.txt],
[crates/sbproxy-extension/src/wasm/mod.rs]) -
E2E proxy startup flake under CPU contention. The e2e
ProxyHarnesskeeps its HTTP-level readiness probe, but now gives
release/debug proxy boots a 10-second window instead of 5 seconds so
tests likeaction_graphqldo not fail spuriously while cargo is
competing for CPU.
([e2e/src/lib.rs]) -
Docs CI Rust snippet failures. Workspace-dependent documentation
examples that cannot compile as standalonerust-scriptprograms are
now taggedrust,no_run, keeping docs-ci focused on executable
snippets instead of illustrative API fragments.
([docs/architecture.md], [docs/audit-log.md], [docs/cache-reserve.md]) -
Unsafe-code drift guardrails. Crates that do not need unsafe now
forbid it at the crate root, whilesbproxy-vaultexplicitly allows
its narrowly-scoped volatile zeroization unsafe with an inline
justification.
([crates/sbproxy-*/src/lib.rs]) -
Outbound webhook delivery identity headers. Signed customer
webhooks now includeSbproxy-Subscription-Id,
Sbproxy-Delivery-Id, and 1-basedSbproxy-Attemptheaders, with a
fresh delivery ULID on every retry attempt.
([crates/sbproxy-observe/src/notify.rs]) -
AI client retry resilience.
MemoryBatchStorenow uses
parking_lot::Mutexso a panic in one worker cannot poison the
in-memory batch map for every later operation. Provider retries now
honorprovider.max_retriesas same-provider retry attempts with
bounded jittered exponential backoff before recording provider
failure and moving to the next eligible provider.
([crates/sbproxy-ai/src/batch.rs],
[crates/sbproxy-ai/src/client.rs]) -
Dynamic Web Bot Auth directory dispatch. The main request auth
path now invokesBotAuthProvider::verify_asyncwhen a configured
hosted directory andSignature-Agentheader are present, so dynamic
directory failures surface distinctly instead of falling through the
static inline-agent verifier.
([crates/sbproxy-core/src/server.rs]) -
ACME/Pebble order polling. Certificate issuance now polls the
authorization tovalidafter responding to the HTTP-01 challenge
before polling the order toready, matching Pebble's stricter state
progression. Finalization also parses the order returned by the
finalize response and falls back to polling the original order URL,
avoiding accidental POST-as-GET polling of the finalize URL when
Locationis absent.
([crates/sbproxy-tls/src/acme.rs]) -
JWKS unknown-
kidkey rotation. JWTs that reference an unseen
kidnow trigger one rate-limited JWKS refetch before failing
closed, with a Prometheus counter for success / failure /
rate-limited outcomes. This avoids requiring operator intervention
for routine IdP key rotation.
([crates/sbproxy-modules/src/auth/jwks.rs],
[crates/sbproxy-modules/src/auth/mod.rs],
[crates/sbproxy-observe/src/metrics.rs]) -
Rate-limit LRU pollution bypass. Per-key local token buckets now
preserve deny state in a bounded cold tier after hot LRU eviction, so
a spray of attacker keys cannot reset an already-throttled
legitimate client.
([crates/sbproxy-modules/src/policy/mod.rs])
Open follow-ups
Tracked in Linear, not in this changeset:
- the upstream issue full configurable
synthetic transaction through the live request pipeline. The
SyntheticProbereadiness primitive has landed; config and pipeline
execution remain. - Phase 2.5: Lua / JS / WASM
featuresnamespace, plus
workspace-level flags via messenger pub/sub - the upstream issue remaining
rate-limiter proptest coverage. The reload-drain loom portion has
landed.
[1.0.1] - 2026-05-04
Patch release. No runtime behavior changes.
Fixed
- Container image publish: the
release.ymlworkflow's docker
prepare step extracted the flat-layout tarballs into/tmp/
directly, which tripped a sticky-bitCannot utimeerror on the
archive's./entry and causedghcr.io/soapbucket/sbproxy:1.0.0
to never publish. Each platform tarball now extracts to a per-arch
staging dir before the binary moves into the docker context.
[1.0.0] - 2026-05-03
First Rust release of SBproxy on this repository.
What changed
- Implementation: SBproxy is now written in Rust on Cloudflare's
Pingora. The Go implementation that previously occupied this repo
(v0.1.0throughv0.1.2) has moved to
soapbucket/sbproxy-go,
preserved as thev0.1.2-go-finalbranch and tag, and is now in
maintenance-only mode. - Data plane: routing, AI gateway, MCP gateway, guardrails, security
policies, and scripting (CEL, Lua, JavaScript, WebAssembly) all ship
open source in this release. Seedocs/architecture.md
for the request pipeline shape. - Enterprise tier: see
docs/enterprise.mdfor
what enterprise adds on top of the OSS data plane and how to request
access.
Upgrading from v0.1.x (Go)
The internal config schema (schema-v1) is supported by both the Go
v0.1.x line and this Rust v1.x line, so existing sb.yml files
should compile unchanged. See MIGRATION.md for the
full upgrade path.