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.
Work that has merged to main since the latest tag and is queued for
the next version cut.
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.
- Sessions. Requests carrying
X-Sb-Session-Id(and optionallyX-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_keysare 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.
- 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.
- 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.
- 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 explicit
version:still wins.
- 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.
A point release fixing operational defects found immediately after the 1.6.0 cut.
- 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.
- 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/statusand 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.
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.
- Managed model deployments. Local serving gains a real control
plane: a canonical
model_host.deploymentsdesired state (existingserve: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 indocs/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 host
uvxpath 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
with
lora_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 a
reference:block; every completion the local model serves is priced at the reference into a durable ledger, andGET /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 lockpins 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.replicationturns 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, orallconsistency 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 sanctioned
mesh_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's
state.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, reversiblesbproxy_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: a
rollout:block under themcpaction'stool_versioningdeclares versions, where each routes, and who gets which. Resolution walks a ladder (per-call_metarequirement, 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 onsbproxy_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.lockfileis 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, andsbproxy_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 explicitresource_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) andguardrail_category/guardrail_actionon every guardrail intervention, mirrored onto the request envelope and the admin request ring;/api/requestsacceptsguardrail_actionandguardrail_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.
sbproxy validateruns the boot path.validate,plan, andapplynow 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 apassword: !env ADMIN_PASSWORDsilently became the literal stringADMIN_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: a
retry_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 onsbproxy_upstream_status_retries_total{origin, status}.
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 eithertimeoutorconnect_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 inSUPPLY-CHAIN.mdnow works with current cosign.
- 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-keyedsbproxy_cache_hits_totalduplicate; the overview dashboard reads the now-livesbproxy_cache_results_totalinstead.
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.
- Duration strings parse consistently everywhere. The
ms/s/m/h/dunits, 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 like1hthat 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.
- Two rate-limit config options that parsed but never enforced anything
are gone. A virtual key's
max_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-modulesrate-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.
- 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 throughuv 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 withengines.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 athttp://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 manifest
revision(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.
A
serve: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, alatestversion) 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 thesbproxybinary'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 (aserve: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 a
serve: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.
- A forward rule whose header matcher names an invalid HTTP header now
fails at config load. The
header: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.
-
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 shared
send_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 by
max_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.
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.
-
Mesh peer mTLS. The mesh peer transport can run over mutually-authenticated TLS: set
key_management.cache.mesh.peer_tlswithcert_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 set
namespace: 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.externallist 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 optionalsite/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.
-
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 and
tools/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.
Patch release. Fixes TLS, which was broken on startup in v1.2.0 and v1.3.0.
- 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.
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.
- 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.
Setting
cache.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)
- The mesh wire encoding moved off the unmaintained
bincodecrate topostcard. - The README and docs now lead with the two-way framing: SBproxy governs the AI you call and the AI that calls you.
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.
- 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/embeddingsendpoint, 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; setauth_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
an
error.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.
- 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.
- Credential selectors are enforced consistently across request paths, and the AI preference script context is exposed to request scripts.
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.
-
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: trueon 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 legacykey_permissions: HashMap<String, Vec<String>>map is removed along withToolAccessPolicy::is_tool_allowed(key, tool); the new surface ispolicy.check(&principal, tool) -> ToolAccessDecisionandpolicy.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). Seedocs/migration-mcp-rbac.md.
-
66 native AI providers behind one OpenAI-compatible API. The embedded
ai_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 viaproxy.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. Seedocs/providers.md#extending-the-provider-catalog. -
Session ledger from live MCP traffic. A new top-level
session_ledger:block makes SBproxy emit the canonicalsession-ledger-v1run record (shared with mcptest) from itstools/callpath: oneheaderper session, then onetool_callrecord 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: filewith 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. Seedocs/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 theRequestEventenvelope (cross-surface JOIN no longer relies onrequest_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 isskip_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 everyAccessLogEntry: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>andserde-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 tosbproxy_request_duration_secondsfor cross-cut dashboards. Seedocs/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 fromorigin, the matched virtual-host pattern);upstream_statusis 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. Seedocs/access-log.md. -
Opt-in OpenTelemetry metrics mirror alongside the canonical Prometheus surface. New
telemetry.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.durationandsbproxy.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 thesbproxy-pluginsurface. -
Web Bot Auth full conformance, publish + sign sides. SBproxy now publishes its own JWKS-shaped directory at
/.well-known/http-message-signatures-directoryand a Signature Agent Card at/.well-known/web-bot-auth/agent-card(opt in viaweb_bot_auth_publishper origin). Newsbproxy-middleware::signatures::MessageSignatureSignerprimitive signs outbound requests per RFC 9421, round-trips through the existing verifier. Seedocs/web-bot-auth.mdandexamples/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 intodocs/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_candtokio::signal::unix::signal(SignalKind::terminate()); before this change the operator relied on the orchestrator SIGKILL atterminationGracePeriodSeconds. The drain budget is the newSBPROXY_SHUTDOWN_GRACE_MSenv var (or--shutdown-grace-msCLI flag) which defaults to 30000ms, matching Kubernetes' defaultterminationGracePeriodSeconds. The legacySB_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 anddocs/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-Keyheaders 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 409ledger.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 onCompiledIdempotency: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-MULTIPARTbecause 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: truestamped 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. Whenproblem_details.enabled: truethe body is now rendered asapplication/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 bymax_request_body_bytesfrom PR #139), then re-injects it viarequest_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 fromdocs/configuration.mdand the example README. -
Idempotency middleware: per-request and pool caps. Three new fields on the
idempotency: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 withx-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_bytesper origin. -
RFC 8594 idempotency middleware (
idempotency:). Per-origin block that engages on POST / PUT / PATCH (configurable viamethods:) 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 byexamples/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_pagesentry. The two blocks compose: per-status custom pages still win when authored;problem_detailscatches everything else with a structuredtype/title/status/detail/instancebody.type_base_uriproduces stable per-statustypeURIs;include_detail: falsesuppresses the internal error string. Documented indocs/configuration.mdand demonstrated byexamples/problem-details/. -
Typed
error_pagesconfig. The opaqueerror_pages: Option<serde_json::Value>field is now typed asOption<Vec<ErrorPageEntry>>. Public typesErrorPageEntry,StatusSpec, andProblemDetailsConfiglive insbproxy-config. The authored YAML shape is unchanged: every existingerror_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 anai_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 to
handle_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), andsbproxy_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 indocs/metrics-stability.md.- At session close,
loggingemits a session-endAiBillingEventwithAudioSeconds { 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) andaudio_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
capability check (only providers in
-
AI gateway OpenAI surface dispatch (Option A). The
ai_proxyaction 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 through
AiClient::forward_with_methodwithout engaging the JSON body-parse pipeline. - Multipart bodies (image edits/variations, audio transcription,
file uploads) byte-forward via
AiClient::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 newprovider_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 andsbproxy_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
AiBillingEventcarryingAiUsagewithTokens,Images { count, resolution },AudioSeconds,Characters,RerankUnits, andPerCallvariants. 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.mddocuments the new surface, methods, guardrails, and rate-limit knobs.
- New
-
Policy verdict audit bus + Plugin dispatch. Wires the previously-dead
Policy::Pluginarm inserver.rsto call the trait'senforce(), folds the returnedPolicyDecisioninto the existing chain reducer, and emits aPolicyVerdictEventfor every decision on a boundedtokio::sync::mpscaudit bus perdocs/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 newsbproxy_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 indocs/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.
SystemResolvernow 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-observenow 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_refnow acceptssecret:references resolved throughsbproxy-vaultwith the existing environment fallback, in addition to the oldersecret_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_capcan 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-fastto skip fat LTO and use more codegen units, cutting link memory/time while leaving production release artifacts on the existingreleaseprofile. ([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/healthadmin 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 aSyntheticProbetype 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])
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 tosbproxy_middleware::idempotency::{IdempotencyCache, InMemoryIdempotencyCache, KvIdempotencyCache}which carries the richer surface (workspace isolation, body-hash conflict detection, conflict body builder).
-
mTLS now wired on the ACME path. Previously, an operator who configured
mtls: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 as
make 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 one
AtomicU64, 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_THREADSandSB_DISABLE_SB_FLAGS; later updates addSB_REQUEST_LOG_LEVELand access-log file/forced-emit examples.
- §6 Health checks:
-
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 whosesubwas not literally"human", even on origins that never configured agent classes. The binding now skips the resolver's fallback /humanverdict 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 (or
ai_providercredential) withmodels.allow/models.blockdeclared 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_modelrewrite): a request for a disallowed model is rejected with403before any upstream call, the block-list taking precedence over the allow-list. Keys with nomodels.alloware unaffected. Seeexamples/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 namespacehttps://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 wildcardhttps://<hostname>/*for the origin-wide license./.well-known/tdmrep.jsonpreviously 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.jsonprogrammatically must update their parsers to the new shapes; the in-process JSON envelope and the response middleware that stampsTDM-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 toprometheus::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 standalone
rust-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, while
sbproxy-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 include
Sbproxy-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 usesparking_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 invokes
BotAuthProvider::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 to
validafter 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 whenLocationis absent. ([crates/sbproxy-tls/src/acme.rs]) -
JWKS unknown-
kidkey rotation. JWTs that reference an unseenkidnow 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])
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.
Patch release. No runtime behavior changes.
- 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.0to never publish. Each platform tarball now extracts to a per-arch staging dir before the binary moves into the docker context.
First Rust release of SBproxy on this repository.
- 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 tosoapbucket/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. See
docs/architecture.mdfor 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.
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.