Skip to content

Latest commit

 

History

History
1766 lines (1607 loc) · 96.2 KB

File metadata and controls

1766 lines (1607 loc) · 96.2 KB

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.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's properties.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.yml remains 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

  • 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 explicit version: 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 --version reports 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 canonical model_host.deployments desired 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 and sbproxy 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/embeddings instead 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: sglang serves safetensors models on CUDA through SGLang, acquired via uvx or 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 host uvx path 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_adapters launches 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 own engine_version / engine_image / engine_sha256 over 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); latest versions 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, and GET /admin/model-host/value reports completions and dollars saved per model. Explicit config only: no reference means no savings claim, never a guessed cloud price.
  • sbproxy update acts on stale artifacts. A plain run now fetches, verifies, and atomically swaps a stale engine prebuilt, and --self replaces the sbproxy binary from its release channel; --check keeps the report-only behavior. A pinned artifact, or one managed elsewhere (a path, brew, or apt engine), is reported and never mutated; the new update.{channel, auto, check_interval} block configures it, and auto only 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-lock reports drift, and --locked refuses to serve anything off-lock. sbproxy models prune reclaims content-addressed weight blobs no cached artifact references.
  • Served-model priority lanes. serve.max_concurrent_requests caps in-flight requests into a local engine behind a queue ordered by the calling key's priority lane (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_mcp on 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-litellm now classifies every unknown key as mapped, warned, or unsupported instead of silently dropping it, and known sink callbacks and max_budget emit 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 choose one, quorum, or all 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 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 rendered proxy.cluster block 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: mesh now 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.md gains 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 adds rag_select, compact_serialization, and position_reorder for explicit line-delimited retrieval blocks. The levers use deterministic ranking, reversible sbproxy_table_v1 encoding, closed fail-open outcomes, and semantic-cache bypass before the final window_fit bound. 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 the mcp action's tool_versioning declares versions, where each routes, and who gets which. Resolution walks a ladder (per-call _meta requirement, per-session requirements declared at initialize, 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/list advertises the consumer's resolved version per tool with the available versions and sunset in _meta; results carry the version that served them. The tool_versioning.lockfile is now optional so the rollout plane works without the version-bump gate. See docs/tool-versioning.md and examples/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_attrs winning 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_action on every guardrail intervention, mirrored onto the request envelope and the admin request ring; /api/requests accepts guardrail_action and guardrail_category filters.
  • 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_tool spans 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 under trace_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 validate runs the boot path. validate, plan, and apply now 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_PASSWORD silently 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: a retry_on entry must be connect_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), and max_attempts above 16 is rejected. Retries land on sbproxy_upstream_status_retries_total{origin, status}.

Fixed

  • retry_on: timeout is honored, in both upstream phases. The token was accepted and documented but nothing consulted it. A connect-phase timeout now retries under either timeout or 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:// and rediss:// 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. initialize results, 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 raw hf:Org/Repo reference in a serve: 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/spend for 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 cyclonedx failed on every published image; the jobs are reordered so the SBOM attestation appends last, and the offline verification recipe in SUPPLY-CHAIN.md now 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-URI secret://<backend>/<name> schemes; a stale reference now fails config load with a migration pointer instead of resolving through a side channel. proxy.secrets.map still 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_total duplicate; the overview dashboard reads the now-live sbproxy_cache_results_total instead.

[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 like 1h30m, decimals like 1.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 like 1h 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's max_tokens_per_minute (and the credential policy's tpm) and an origin's per-origin rate_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 workspace rate_limits: budget, and the AI gateway's model_rate_limits / per-surface limits, all still enforce.
  • Two build-only feature flags that nothing enabled were removed (sbproxy-platform/postgres-store and an unused sbproxy-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 fetching uv (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 doctor reports it as the recommended vLLM path.
  • sbproxy update: is any of it out of date. A dry-run freshness report: sbproxy update checks the inference engine release feed (the pinned llama.cpp prebuilt vs the latest) and the cached models (flagging any that track a moving ref like main and could be behind upstream); --self also checks the sbproxy binary against its release channel. --json for 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 (an api_key, client_secret, token, ...) are masked; secret references (vault://, ${ENV}, file:, ...) are shown, since they are pointers, not the secret. --json for tooling, YAML by default.
  • sbproxy models list / show: discover what this host can run. sbproxy models (or models list) prints one row per catalog model with a real per-GPU fit verdict (reusing the same probe doctor uses), 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. --json on both for scripts and the admin UI; --catalog-file points 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 (or sbproxy run hf:Org/Repo:Q4_K_M --name coder) synthesizes a minimal serving config, checks the model can run on this host (the same detection sbproxy doctor uses, 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 and localhost route). The engine and weights are acquired on the first request. Flags override the port, engine, acceleration, and cache directory; --dry-run prints 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-coded main) and verifies the per-file sha256 when 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 its config.json on 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-engine engines.<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, while source: path points 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 (a path source with no path, a latest 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, so engine: auto can 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 an nvidia-smi fallback) and model-weights (Hugging Face weight download) features moved into the sbproxy 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-weights is no longer needed for local model serving. Library consumers of the workspace crates still opt in per crate.
  • sbproxy doctor is 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) the serve: admission path sees, NVIDIA driver and CUDA / Metal / ROCm, container runtimes and daemon liveness, package managers, Python and uv, and Hugging Face reach plus whether HF_TOKEN is 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, per serve: model, what engine: auto resolves to and a coarse fit preview, and exits non-zero when a configured model has no viable engine. --format json emits 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. Set SBPROXY_CPU_MEMORY_FRACTION=0 to opt back into rejecting admission on a GPU-less host. The weight cache defaults to ~/.cache/sbproxy/models for a non-root run (and the service path /var/lib/sbproxy/models when running as root), so serving works out of the box without configuring cache_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. The header: matcher on a forward_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_map configured, 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 unless failure_mode_allow is 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_error helper 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 AI model name) 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.

[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: set key_management.cache.mesh.peer_tls with cert_file, key_file, and ca_file (plus an optional server_name, default sbproxy-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: always to expose every tool as <prefix>.<tool> and every resource as <prefix>/<uri>, where the prefix is the server's prefix (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_only records only), and a transport or parse error honors each entry's fail_open flag. Provider presets shape the request and response for Presidio (/analyze with 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_sinks now accepts type: langfuse (host plus public/secret key; posts a generation observation to /api/public/ingestion) and type: datadog (api_key plus 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 limit with a period (daily, monthly, or a duration like 30d) 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 (no period, or total / 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/list showed 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 h2 in 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. New key_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: mesh keeps 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-litellm translator, 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 bincode crate 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: sidecar runs the embedder in the supervised classifier sidecar; source: inprocess loads an ONNX model (all-MiniLM-L6-v2 by default) into the proxy behind an explicit opt-in and a max_model_bytes guard. 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 to Authorization: Bearer; set auth_header / auth_prefix for api-key / x-api-key endpoints, or carry the credential in arbitrary extra headers.
  • 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.

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 a vault://<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: ToolAccessPolicy flipped from open-by-default to closed-by-default. An unknown caller (no matching ACL rule) is denied every tool. An empty allowed: [] list under an ACL rule means "deny all", not "allow all". Operators who want the legacy behaviour add default_allow: true on the origin's MCP action. The legacy key_permissions: { key: [tools] } shape is gone; rewrite to the principal-aware tool_access[] selector list. See docs/migration-mcp-rbac.md.

  • MCP principal-aware ACL: ToolAccessPolicy now carries tool_access[] rules with principals[] selectors (virtual_key, sub, team, project, user, role, tenant_id) plus an allowed[] tool list. The legacy key_permissions: HashMap<String, Vec<String>> map is removed along with ToolAccessPolicy::is_tool_allowed(key, tool); the new surface is policy.check(&principal, tool) -> ToolAccessDecision and policy.filter_tools(&principal, &tools). tools/list now filters by RBAC against the inbound principal (the legacy schema leaked tool names through tools/list even when the gate would deny the matching tools/call). A new tool_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 embedded ai_providers.yml registry 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; the model field 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-v1 run record (shared with mcptest) from its tools/call path: one header per session, then one tool_call record per call carrying session_id, a zero-based hop_index, the bare tool name and server, redacted params / result, an error flag, and the round-trip duration_ms. sink: logging (default) emits each record as a session_ledger tracing line; sink: file with a path: appends NDJSON. Off unless enabled: 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.md and examples/mcp-federation/sb.yml.

  • Structured-log schema v2 (SCHEMA_VERSION = "2"). Three changes land together so downstream tooling can read them in one swing: optional session_id and user_id top-level fields parallel the RequestEvent envelope (cross-surface JOIN no longer relies on request_id alone); 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_seconds Prometheus histogram. The access log carried latency_ms end 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 of response_filter). All three are Option<f64> and serde-skip when None, so origins that short-circuit (cache hit, auth deny) keep compact lines. The same observations also feed a new sbproxy_phase_duration_seconds{phase, origin} histogram with buckets identical to sbproxy_request_duration_seconds for cross-cut dashboards. See docs/access-log.md and docs/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). host is 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 are Option, serde-skip when 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. New telemetry.export_metrics: true (with telemetry.metrics_interval_secs cadence, default 30s) installs an OTel MeterProvider that ships observations to the same OTLP collector the trace pipeline targets. The first two mirrored instruments are sbproxy.phase.duration and 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-configuration discovery, 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. See docs/configuration.md § OIDC auth.

  • OpenAI Apps SDK / MCP Apps (SEP-1865) compatibility. Gateway-side _meta.mcpApps passthrough for tool definitions, params.audit.cause plumbing on tools/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-plugin surface.

  • 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_publish per origin). New sbproxy-middleware::signatures::MessageSignatureSigner primitive signs outbound requests per RFC 9421, round-trips through the existing verifier. See docs/web-bot-auth.md and 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). See docs/object-authz.md, docs/content-digest.md, docs/agent-budget.md.

  • Discoverable FAQ. docs/faq.md covers install, common 401 causes, OIDC minimal config, log levels, OSS-vs-enterprise scope, and pointers into the rest of docs/. Wired into docs/README.md under "Getting started".

  • Explicit SIGINT/SIGTERM handling with a structured shutdown event and a 30s default drain budget. Pingora's Server::run_forever already trapped SIGTERM and SIGINT, but the proxy emitted no operator-facing log line on receipt, so a pod eviction or docker stop looked the same as a crash in the log stream. This change subscribes to Pingora's execution-phase broadcast and emits shutdown_signal_received, shutdown_grace_period, and shutdown_complete tracing events with the resolved grace budget. The Kubernetes operator (sbproxy-k8s-operator) now installs the same SIGINT/SIGTERM handlers via tokio::signal::ctrl_c and 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_MS env var (or --shutdown-grace-ms CLI flag) which defaults to 30000ms, matching Kubernetes' default terminationGracePeriodSeconds. 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 in docs/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 using Idempotency-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 in handle_ai_proxy after 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 with x-sbproxy-idempotency: HIT and never contacts the provider. On a body conflict the gateway returns 409 ledger.idempotency_conflict per 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 with SKIPPED-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_status and problem_details now cover upstream failures. Before this change, proxy_status.enabled: true stamped the Proxy-Status header on proxy-generated errors (auth deny, policy deny, default 404) but not on upstream failures routed through Pingora's fail_to_proxy path (connect refused, connect timeout, TLS handshake error, mid-stream connection loss). The fix wires both blocks into the upstream-failure path so dashboards consuming Proxy-Status see consistent coverage across error sources. The status code + RFC 9209 error token derive from the Pingora ErrorType via a new map_upstream_failure translator: 504 + connection_timeout for ConnectTimedout / ReadTimedout; 502 + connection_refused for ConnectRefused; 502 + tls_protocol_error for TLS errors; 502 + connection_terminated for mid-stream loss; 502 + http_request_error as the catch-all. When problem_details.enabled: true the body is now rendered as application/problem+json for upstream failures too, with the RFC 9209 error token in the detail field so both signals share the same vocabulary.

  • Idempotency cache check moved to request_filter. Before this change, the cache lookup ran in request_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 inside request_filter and return Ok(true), so the upstream is never contacted at all. On cache miss the proxy buffers the body (bounded by max_request_body_bytes from PR #139), then re-injects it via request_body_filter at 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.md and 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 with x-sbproxy-idempotency: SKIPPED-OVERSIZE-REQUEST stamped 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 with x-sbproxy-idempotency: SKIPPED-POOL-FULL. Worst-case memory is bounded at max_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 an Idempotency-Key header 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 with x-sbproxy-idempotency: HIT; conflicts (same key, different body) return 409 with the ledger.idempotency_conflict JSON 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: redis binds to proxy.l2_store at config-compile time for cluster-wide replay. Cached replays do not consume rate-limit slots. Documented in docs/configuration.md and demonstrated by examples/idempotency/. Known v1 limitation: the cache check fires in request_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 to application/problem+json for proxy-generated errors (authentication denials, policy denials, default 404) that are not matched by an authored error_pages entry. The two blocks compose: per-status custom pages still win when authored; problem_details catches everything else with a structured type / title / status / detail / instance body. type_base_uri produces stable per-status type URIs; include_detail: false suppresses the internal error string. Documented in docs/configuration.md and demonstrated by examples/problem-details/.

  • Typed error_pages config. The opaque error_pages: Option<serde_json::Value> field is now typed as Option<Vec<ErrorPageEntry>>. Public types ErrorPageEntry, StatusSpec, and ProblemDetailsConfig live in sbproxy-config. The authored YAML shape is unchanged: every existing error_pages: list keeps parsing, including the status: single- int / [status] list shorthand and template: true substitution. The OpenAPI emitter now walks typed entries to populate per-status responses keys (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/realtime requests with Upgrade: websocket against an ai_proxy origin are now dispatched through the AI gateway pipeline:

    • Pre-upgrade gating runs the same surface classification, 501 capability check (only providers in provider_supports_realtime are 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_action continues 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, logging emits a session-end AiBillingEvent with AudioSeconds { 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 in sbproxy-ai::realtime for the eventual terminate-and-relay path to consume.
    • docs/ai-gateway.md documents the new dispatch path with a YAML example and the per-surface rate-limit knob.
  • 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 AiSurface enum + 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_method without engaging the JSON body-parse pipeline.
    • Multipart bodies (image edits/variations, audio transcription, file uploads) byte-forward via AiClient::forward_bytes with the inbound Content-Type preserved. Previously these surfaces returned a 400 "invalid JSON body" from the chat-path body parse.
    • Provider capability matrix in api_routes.rs corrected: Anthropic no longer claims audio/reranking/moderations support, Gemini no longer claims moderations. A new provider_supports_surface matrix 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 in docs/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-style messages.
    • Per-surface rate limits: new per_surface_rate_limits field 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 AiBillingEvent carrying AiUsage with Tokens, Images { count, resolution }, AudioSeconds, Characters, RerankUnits, and PerCall 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.
  • Policy verdict audit bus + Plugin dispatch. Wires the previously-dead Policy::Plugin arm in server.rs to call the trait's enforce(), folds the returned PolicyDecision into the existing chain reducer, and emits a PolicyVerdictEvent for every decision on a bounded tokio::sync::mpsc audit 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 from docs/adr-policy-verdict-shape.md are implemented at the chain level: any Deny wins, the first Confirm wins over AllowWithHeaders, AllowWithHeaders accumulate, otherwise Allow. Confirm in OSS routes through the existing AllowWithHeaders mechanism with X-Policy-Confirm: <reason> stamped on the response; an expires_at already in the past synthesises a 410 and an SSRF-blocked webhook_url synthesises 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 dashboard sbproxy-policy-verdicts covers 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 /readyz probe. Optional background driver that fires an in-process request through the compiled handler chain on a fixed cadence and reports the verdict as a synthetic_pipeline component on /readyz. Disabled by default; opt in via proxy.synthetic_probe.enabled: true and 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/drift config 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. ClockSkewMonitor now 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 uses hickory-resolver for 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 and AlertSnapshot replay 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 accepts secret: references resolved through sbproxy-vault with the existing environment fallback, in addition to the older secret_ref.env and inline seed_hex paths. ([crates/sbproxy-modules/src/policy/ai_crawl.rs])

  • Operator first-24-hours quickstart. Added a concise docs/quickstart-operator.md covering 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 the hostname label 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-fast build profile for CI images. Docker-based CI and local kind smoke-test builds can now use CARGO_PROFILE=release-fast to skip fat LTO and use more codegen units, cutting link memory/time while leaving production release artifacts on the existing release 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 the x-sb-flags header 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-form k=v extras surface as strings (features["env"]). Wired into the rate-limit CEL evaluator and ExpressionPolicy::evaluate_with_views. ([crates/sbproxy-extension/src/cel/context.rs])

  • SB_WORKER_THREADS env 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. /livez returns {"alive":true} on every call and never 503s, so K8s liveness probes don't trip on transient readiness failures. /live is a bare alias. /ready is an alias for /readyz. /healthz stays a fixed liveness body, while /health now returns version, build hash, timestamp, uptime, and readiness checks for dashboards / SIEM ingestion. Existing /readyz behavior unchanged. ([crates/sbproxy-observe/src/health.rs], [crates/sbproxy-core/src/admin.rs])

  • --request-log-level and SB_REQUEST_LOG_LEVEL. Operators can now tune request/access logging independently from application logs. The setting appends an access_log=<level> target directive to the effective tracing-subscriber filter while preserving the existing per-target RUST_LOG escape hatch. ([crates/sbproxy/src/main.rs])

  • Access-log forced emission and file output. access_log now supports slow_request_threshold_ms and always_log_errors so slow requests and 5xxs bypass sampling after status/method filters match. It also supports output: { 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 into CertResolver::update_fallback_ocsp so 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-observe now ships a SyntheticProbe type so startup or test wiring can register an in-process readiness probe that exercises a caller-provided path and reports through the same /readyz component 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 new idempotency: block on general HTTP origins (above) supersedes it. AI gateway integration is a follow-up tracked in docs/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 configured mtls: alongside acme: got plain TLS until they noticed clients reaching the upstream without the expected cert headers. The ACME branch now mirrors the manual-cert branch: builds TlsSettings with the configured ClientCertVerifier and 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-smoke and make k8s-operator-smoke for 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, so is_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 /readyz by default. The default admin health registry now registers absent ledger and bot-auth-directory probes as not_configured, matching the existing future-wave stubs and keeping /readyz green when those optional services are not wired in a deployment. ([crates/sbproxy-observe/src/health.rs], [crates/sbproxy-core/src/admin.rs])

  • docs/manual.md rewrites matching what actually ships:

    • §6 Health checks: /livez, /readyz, /healthz, and rich /health semantics, replacing the old per-endpoint URL fork diagram and stale /health alias 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_THREADS knob.
    • §13 env-var table: adds SB_WORKER_THREADS and SB_DISABLE_SB_FLAGS; later updates add SB_REQUEST_LOG_LEVEL and access-log file/forced-emit examples.

Fixed

  • CAP sub binding only fires for a genuinely resolved agent. The CAP verifier binds a token's sub to the request's resolved agent id (rejecting a mismatch with 403). Because the agent-class resolver is installed with the built-in catalog by default and always stamps some id (falling through to the human sentinel when no signal matches), the binding would have rejected every CAP token whose sub 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. Set cap.require_agent_binding: true to fail closed when no agent is resolved.

  • Virtual-key model allow/block lists are now enforced. A virtual key (or ai_provider credential) with models.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 any route_to_model rewrite): a request for a disallowed model is rejected with 403 before any upstream call, the block-list taking precedence over the allow-list. Keys with no models.allow are 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.xml previously declared the namespace https://rsl.ai/spec/1.0 and emitted a flat <rsl><license urn=...>...</license></rsl> document. The canonical RSL Collective spec at https://rslstandard.org/rsl uses the namespace https://rslstandard.org/rsl and a nested <rsl><content url="..."><license>...</license></content></rsl> shape; the <content> url attribute 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 with location, tdm-reservation (integer 0 or 1), and tdm-policy (URL of the policy document) fields per entry. Both emitters now produce the canonical shapes. Operators consuming /licenses.xml or /.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: 1 and the URN-bearing license field 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::metrics and sbproxy-core::server that passed heterogeneous &[&String, &str] arrays to prometheus::with_label_values no longer compile on prometheus 0.14 because Rust unifies the array element type to &String and rejects bare &str literals. 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.md previously 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.txt also incorrectly claimed "WASI networking with host allowlist" but allowed_hosts is 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 ProxyHarness keeps its HTTP-level readiness probe, but now gives release/debug proxy boots a 10-second window instead of 5 seconds so tests like action_graphql do 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-script programs are now tagged rust,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-vault explicitly 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-based Sbproxy-Attempt headers, with a fresh delivery ULID on every retry attempt. ([crates/sbproxy-observe/src/notify.rs])

  • AI client retry resilience. MemoryBatchStore now uses parking_lot::Mutex so a panic in one worker cannot poison the in-memory batch map for every later operation. Provider retries now honor provider.max_retries as 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_async when a configured hosted directory and Signature-Agent header 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 valid after responding to the HTTP-01 challenge before polling the order to ready, 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 Location is absent. ([crates/sbproxy-tls/src/acme.rs])

  • JWKS unknown-kid key rotation. JWTs that reference an unseen kid now 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 SyntheticProbe readiness primitive has landed; config and pipeline execution remain.
  • Phase 2.5: Lua / JS / WASM features namespace, 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.yml workflow's docker prepare step extracted the flat-layout tarballs into /tmp/ directly, which tripped a sticky-bit Cannot utime error on the archive's ./ entry and caused ghcr.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.0 through v0.1.2) has moved to soapbucket/sbproxy-go, preserved as the v0.1.2-go-final branch 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.md for the request pipeline shape.
  • Enterprise tier: see docs/enterprise.md for 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.