All notable changes to Busbar are documented here. The format is based on Keep a Changelog and the project follows Semantic Versioning.
An emergency log-spam hotfix. 1.5.4 is otherwise fine, but if you run Busbar with a durable audit store
(store: sqlite/postgres) it floods the log with WARN lines — up to ~10 per second — for a condition
that is expected and harmless. There is no config change and no behaviour change; only the log severity
of two durable-audit write-through cases changes.
- The durable-audit write-through no longer spams the log. The periodic write-behind flusher
re-offers the audit tail to the durable store every tick (~10/s), and two of its skip paths logged a
WARN on every one of those ticks:
- A recovered durable floor sitting ABOVE a freshly-numbered RAM-ring seq — the normal state right after a restart against a durable store — made the write-through skip that already-durable seq and WARN each time. This is expected and benign, so it is now logged at DEBUG.
- A permanent hash-chain gap (a seq pruned from the RAM ring before it could be persisted, which can never be backfilled in-process) is a genuine data-loss signal, but it was re-WARNed on every flush forever. It now WARNs exactly ONCE for a given gap seq (with the seq), and logs at DEBUG on every subsequent flush of the same hole.
A hotfix. 1.5.3's Docker image does not start, so if you run Busbar in a container, upgrade. There is no config change and no behaviour change beyond the two fixes below.
-
The Docker image starts again.
docker run getbusbar/busbar:1.5.3exited 1 before binding a port, with "the overlay backend '/etc/busbar/busbar-overlay.json' is not writable". The image runs as an unprivileged user on a read-only/etc/busbar, and the documented quickstart mounts yourconfig.yamlread-only on top of that, so the config overlay 1.5.3 introduced had nowhere to write — and 1.5.3 treated that as a reason to refuse to start. It no longer does. Busbar boots, serves traffic, warns clearly at startup that it has no durable config overlay, and refuses admin-API config changes outright rather than applying them in memory and losing them on restart. If you want those changes to persist, pointconfig.overlay.fileat a writable volume; if you never wanted them,config.locked: truesays so explicitly and silences the warning. A config that explicitly setsconfig.overlay: falsewhile remaining mutable still refuses to start, because that one is a contradiction you can only reach by writing it down. (#50) -
First-party plugins verify on ARM Linux.
busbar-aarch64-unknown-linux-gnushipped without the embedded Busbar release public key in 1.5.1, 1.5.2 and 1.5.3, so every correctly signed first-party plugin was refused on ARM Linux — and only there — as unsigned. That target was the one platform built by cross-compiling inside a container the key never reached; it now builds on a native ARM runner. The release build additionally refuses to compile at all without a well-formed key, and asserts the key is present in the finished artifact before it is uploaded, so this cannot recur silently on any platform. (#52)
- The release pipeline boots the container image and checks
/healthzbefore any tag,latestincluded, is allowed to point at it. Nothing had ever started the image before publishing it. - The post-release verifier runs all of its checks and reports every failure, instead of stopping at the first. A check that could not reach getbusbar.com from CI was aborting the run before the check that boots the published image, which is why the defect above stayed invisible for six days.
This release reshapes the config file, so give yourself a few minutes for the upgrade.
busbar --migrate-config <config.yaml> does most of it for you and tells you what it changed. Busbar will
not start on the old spellings, which is deliberate: a config that quietly means something different is
worse than one that stops and says so.
Every breaking change below is a config change. If you would rather see the finished shape than a list of edits, config at a glance is one annotated file with every section on a single page, and the 1.5 migration guide walks the path from 1.4.
busbar --validatenow resolvesenv:andfile:secret references, and exits 1 when one of them cannot be resolved. It previously checked only that the reference was well formed and exited 0, so a config naming an unset variable reported "ok: config valid". A CI job that runs--validatewithout production secrets in its environment will now go red: give that job the environment variables and files your config names, or point it at a config whose references resolve there. Boot is unchanged, an unresolvable reference still logs a warning and Busbar still serves. See the operations guide.- The Redis-protocol store plugin is now Valkey. Change
store.module: redistovalkeyand install thebusbar-store-valkeyartifact. Your connection URL does not change. Re-pin any plugin version pin under the new name, and delete the old plugin file from your plugin directory. - Hooks are defined once by name and attached by name. Inline hook definitions and
global_hooks:no longer load: define each hook under the top-levelhooks:block and list its name underpools.hooks:(every pool) or under one pool. Stage names are nowrequest,candidate,routingandresponse; the oldroute,attemptandcompletionfail at startup. A pool may not be namedhooks. A hand-written hook with no stage list now fires at all four stages rather than once per request; setphase: [request]for the old behaviour. See the hooks guide. - Identity providers are defined once by name and referenced by name. Define each under the
top-level
identity-providers:block;auth.chain:andauth.admin_auth:are lists of those names. Theauth.methods:block is gone and its contents belong on the provider,auth.role_bindings:is keyed by provider name, and an unstated admin trust ceiling is now the most restrictive one. A ceiling can only be raised in the config file, never through the admin API. - Export sinks are named, and
observability:is gone. Writeexport:as<your-name>: {module, settings}rather than keyed by type, which lets you run two sinks of one kind, for example one request log to your own store and one to a SIEM.generic-webhookis now part ofrequest-log-webhook. Moveobservability.otlp_urlto an export sink using theotlpmodule. See the observability guide. - Response headers are off by default. Everything Busbar used to add to a response, timing
and route headers included, must be enabled under
advanced.response_headers.observability.emit_server_timingno longer exists. Enable what your dashboards and clients read. admin_insecureis nowadmin_require_mtls, with the meaning reversed and safe by default. A network-exposed admin listener with no client CA still refuses to start; the waiver is nowadmin_require_mtls: false.- Upstream credentials are configured per pool.
auth.upstream_credentialsmoves topools.upstream_credentials, and any pool can override it.
- Identity providers and export sinks can be managed through the admin API, as hooks already were. See the admin API reference.
- Config changes made through the admin API now survive a restart out of the box. Set
config.locked: trueto make the file the only way to change configuration. - Plugins can serve their own HTTP endpoints.
- A plugin's own log lines now reach your log sink. Store, auth, hook and secret plugins previously had
their logging discarded or written straight to stderr, so lines like a failed token-signature check or
an ambiguous directory match never appeared. They now arrive through Busbar's logging with their level
and structured fields intact, named by plugin, and are filtered by
RUST_LOGlike everything else. Existing signed plugin artifacts keep loading unchanged. - A guide to pointing Busbar at a local inference server (Ollama, LM Studio, llama.cpp, vLLM): what to
put in your own
providers.yaml, how local members mix with hosted ones in a pool, and what changes when Busbar runs in Docker. See the providers guide.
- Operational settings that were environment variables are now config keys:
BUSBAR_PROVIDERS,BUSBAR_CONFIG_OVERLAY,BUSBAR_WORKER_THREADS,BUSBAR_UPSTREAM_HTTP1_ONLYandBUSBAR_UPSTREAM_H2_PRIOR_KNOWLEDGE. Each still works for one more release, and the config key wins if you set both.BUSBAR_CONFIGis unchanged. - The
persistfield on admin config calls is ignored: durability is now a property of the deployment. - The admin hooks API calls the field
modulerather thanplugin, matching the config file.pluginis still accepted. - Every durable store now answers the same way for the same request, where the answer used to depend on which store you had deployed. Deleting a key that never existed is an error rather than a silent success, deleting one already deleted stays a success, revoking an already-revoked credential is a success while revoking an unknown one is an error, and an audit write that lands on an occupied position is a success only if the record is identical and an error if it differs. Tooling that read a lenient backend's silent success as confirmation should be checked.
- Admin reads returned the raw values of a module's settings, including credentials such as a client secret or a store password. They now return only the setting names.
- Deleting or rotating a key could return an error while the key went on working, and flushing the authentication cache could return success without revoking anything.
- An admin deletion of a user's self-serve key survived only until that user's next login, which silently recreated the deleted credential and put every token minted before the deletion back into service. The deletion now stands.
- Rotating a user's self-serve key and then changing their group's pools left the user holding two valid keys at once, each metering and enforcing budget separately, so spend was counted against two buckets and neither reflected the real total.
- A hook that could not reach its own dependency had no way to say so, so it read as "no opinion" and
a gate configured with
on_error: rejectadmitted the request instead of refusing it. A hook can now report the failure andon_errorapplies. See the hooks guide. - Busbar sent an empty
client_secretwhen exchanging a code for a public identity-provider client. An identity provider is entitled to read an empty secret as a wrong one and answerinvalid_client, so browser login against a public client could fail outright. The parameter is now omitted when there is no secret; a confidential client is unaffected. - The SSRF guard on an OTLP export sink checked only the literal text of the collector endpoint, so
https://169.254.169.254/v1/traceswas blocked while a hostname resolving to that same cloud metadata address was allowed through. Span data carries key ids, pool names and governance decisions, so the endpoint is now resolved and every resulting address is checked. A collector whose DNS is briefly unavailable is not treated as a rejection. See the observability guide. - Budget accounting could allow spend it should have blocked: an exhausted lifetime budget on a group with an email-shaped name reset to zero on restart, deleting one principal could reclaim another's budget, and deleted groups left budget entries behind that no admin call could see.
- Admin config writes could report success without taking effect. An unknown field was accepted then
dropped at reload,
config.lockedwas not enforced on two endpoints, and a write with nowhere to persist returned success. - An identity provider's
max_admin_scopewas ignored, leaving it read-only even when you granted more. busbar --migrate-configcould change or drop what you wrote: a hook attached with a single value rather than a list migrated to a pool with no hooks and still passed--validate, so a compliance gate could vanish silently; an unrecognized budget period became a lifetime cap; a yearly budget carried onto a monthly window unrescaled, a twelve-fold increase; and a provider used on both planes could lose one plane's settings.- Busbar could refuse to start in a writable directory when the config file was named with no directory path.
- The request-log file export could grow without bound if the destination stalled, and every webhook export shared one queue limit, so a slow sink could consume capacity you had capped elsewhere.
- A hook whose settings referenced a secret reported a settings mismatch on every check, forever.
advanced.worker_threads: 0was silently ignored instead of reported.
auth.chain: [keys]with no way to mint an admin token now refuses to start. It previously booted as a silent open relay admitting anonymous requests. Giveauth.admin_authanadmin-tokensentry with atoken:, or an admin module grantingmintorfull, or set an explicitadmin_auth: []for development. See the 1.5 migration guide.
- Setting an admin token no longer forces data-plane requests to carry a virtual key, so
chain: []plus an admin token is now an open relay with a protected admin API.
- Busbar no longer generates a signing key at boot. If
auth.chainnames the built-inkeysverifier,auth.signing_keyis required and startup fails without it. Generate one withbusbar --generate-signing-keyand pointauth.signing_keyat a file or environment variable. It is fleet-shared, so generate once and distribute to every node; rotating it revokes every outstanding key. 1.5.0 wrote this file itself beside your config, which boot-looped on a read-only mount.
/statsand/metricsreport why a lane cannot take a request (at capacity, breaker open, dead, budget exhausted), when it might recover, and how many requests are parked. See the observability guide.on_exhausted: { queue: { max_ms } }holds a request for a bounded wait when every pool member is at capacity, then dispatches it or falls through toreject.
- A pool whose members are all at
max_concurrentnow spills or sheds peron_exhaustedinstead of queueing to the failover deadline, so a burst against a small pool with a cloud overflow pool spills immediately rather than serializing. busbar_lane_at_capacityis replaced bybusbar_lane_available. Update dashboards that use it.
on_exhausted: least_badreturned a 503 when the best member was at capacity even though a sibling had a free slot.Retry-Afteron an exhaustion 503 always said one second under saturation, rather than the real cooldown.limits.max_inbound_concurrentqueued excess requests behind the cap instead of shedding them, so clients got no backpressure.
The config, identity and cost release. The config file changed shape and every 1.4.x virtual key stops
working, so plan the migration and the key rotation together. The data-plane HTTP surface is unaffected: an
application posting to /v1/chat/completions gets a byte-identical response after the upgrade.
- The config file changed shape and a 1.x config refuses to start. Run
busbar --migrate-config <old.yaml> > config.yaml, review every WARNING and TODO it prints, then runbusbar --validate. Read everyallowed_pools: []carefully: its meaning flipped from all pools to no pools. - Every 1.4.x virtual key stops working and must be re-minted through
POST /api/v1/admin/keys, with the new tokens rolled out to callers. Keys are now signed tokens that expire (90 days by default) and can be revoked fleet-wide, where a 1.x key was a bearer secret that never expired. - A durable store is dropped and recreated on first open. Usage history resets with it.
- Limits moved off keys and onto groups.
rpm_limit,tpm_limit,max_budget_centsandbudget_periodare gone from minting, fromPATCH /keys/{id}and from key metadata; a key resolves to a group and the group carries the limits. The per-keybusbar_key_budget_remaining_centsgauge is gone with them, so use the bucket gauges. - The
governance:block is gone.store,rate_card,per_request_fee,groupsandadvancedare top-level, and the admin token is a secret reference on theadmin-tokensmodule.governance.enabledandgovernance.budget_on_store_errorno longer exist. Handled by--migrate-config. - Static token auth is gone. The
tokensmodule andauth.client_tokensare removed; data-plane auth is the built-inkeysverifier or an identity provider. - The top-level
hooks:registry is gone, with the hookglobal:anddefault:flags. A hook instance is referenced inline in a pool'shooks:list or inglobal_hooks:. (Reversed in 1.5.3, which restores a namedhooks:definition map.) cost_per_mtokon pool members andgovernance.price_per_1k_tokens_centsare gone.rate_card:is the only cost source;--migrate-configsynthesizes entries and flags them for review.- Config aliases are gone, one canonical name each.
window_sbecomeswindow_secs,nbecomesconsecutive_n,deadline_secsbecomestimeout_secs,capbecomesmax_hops,otlp_endpointbecomesotlp_url, a member'stargetbecomesmodel,api_key_envbecomesapi_key: { env: ... }, andauth.modebecomesauth.chainplusauth.upstream_credentials.
groups:is the one place limits live: a named tree where requests, tokens, budget and concurrency all use one shape. Admission checks every group up the chain and a rejection names the bucket that blocked it. A user is just a leaf group under their team. See the configuration guide.- A limit can carry
pool: <name>, so a team's spend splits across model tiers and exhausting the frontier budget stops only frontier traffic. - A pool-scoped budget can declare
on_exhaust: downgradewithdowngrade_to: <pool>, so running out routes to a cheaper pool instead of refusing the request. - Groups are editable live over the admin API with no restart, past accrual survives the edit, and
per-group usage is readable at
GET /api/v1/admin/groups/{name}/usage. POST /api/v1/admin/keyscan auto-provision a personal group under a parent, and the newmintadmin scope lets a portal issue keys without full admin rights.limits.max_keys_per_principalcaps how many keys one principal may hold.rate_card:is the only source of cost, priced per model and tier. Omit it and everything prices at zero; include it and it must be complete, with a missing model failing startup with a paste-ready stub.- Every secret in the config is a reference:
{ env: VAR },{ file: /path }, or a secret plugin for a vault or cloud secret manager. - Durable stores are plugins. SQLite, Postgres and Valkey ship as signed tarballs you install and name
in
store.module; the compiled-inmemorystore is still the zero-setup default. See the plugins guide. - Store, secret, identity and hook plugins share one signed artifact format and trust model. Unsigned,
tampered or unknown-publisher plugins are skipped and never loaded;
trust.allow_unsignedandtrust.allow_third_partyare opt-ins that default to off, andplugins.min_versionssets anti-downgrade floors. - Identity providers are plugins: name one in
auth.chainand it loads at boot, and one that cannot load is a hard startup failure rather than a silently open front door. The bundledoidcmodule is the first. - Hooks are signed plugins loaded in process. Two ship with this release:
busbar-headroom-hookcompresses prompts before dispatch, andbusbar-webrequest-hookforwards to an HTTPS sidecar you run yourself. The socket and webhook transports remain as built-in hook modules. - Plugins can be listed, installed, removed, hot-reloaded and rolled back over the admin API with the same trust checks boot applies. Changing the store module still needs a restart.
GET/PUT /api/v1/admin/config/settingscovers every config section, andPOST /api/v1/admin/restartapplies the settings that need a restart (listeners, TLS, store backend) without shell access.- Admin config changes persist to a Busbar-owned overlay file and your
config.yamlis never written.DELETE /api/v1/admin/overlay/{section}reverts one section back to the file. busbar --validatecovers the whole new surface with paste-ready fixes, andbusbar --list-pluginsprints the plugin inventory without loading plugin code.- Spend, budget-remaining and token metrics are labelled by group and window, and key labels set at mint time echo onto per-key series, so a dashboard can sum by team.
- The SemVer contract is now stated explicitly: the frozen surface is the data-plane HTTP surface and the
wire protocols.
config.yamlis an operator artifact outside that freeze and may change between releases, always with a migration path and a loud failure on an outdated config. The admin API carries its own version. - Spend is derived, not stored. The store keeps a token ledger and money is computed at read time from the current rate card, so correcting a rate is a config edit and a reload with no re-billing.
PATCH /keys/{id}takesenabledandgrouponly; the 1.4.x cap fields are rejected.- A hook granted
prompt: roorprompt: rwnow also sees reasoning and thinking text, which it could not see before even though that text reached the provider in full. Nothing to configure, but review any path where your hook forwards or logs that projection. Opaque redacted reasoning is still never plaintext.
- An exhausted budget could be spent again: a request straddling a window boundary could rewind a live budget cell and zero its totals, a store error while loading budgets at boot started with empty counters, and a large enough ledger overflowed the derived total to a negative number that read as free.
- A caller could escape the
requestslimit by hammering failing requests, because the refund on a non-2xx outcome also refunded the admission slot. - An identity provider could hand a caller a principal id shaped like a real key or group and take over that budget bucket.
- A typo in a security-relevant config key was silently ignored, so
client_c:forclient_ca:disabled mTLS without complaint. Unknown fields now fail startup. - Concurrent budget flushes could double-count spend against a shared store, the Valkey store wrote duplicate audit entries, and store errors could include the connection password.
- An environment variable interpolated into the config could splice extra structure into it, for example widening an allowlist.
- The repository moved to
github.com/GetBusbar/busbar; older links redirect. Verify release artifacts with--repo GetBusbar/busbar. The Docker Hub image is unchanged.
- Every tagged release attaches the admin API's OpenAPI document, so you can generate a client or diff the API surface without running the gateway.
- Google Vertex AI, Azure OpenAI and Oracle OCI Generative AI, all as configuration rather than code. See the providers guide.
- Two new ways to authenticate outward to a provider:
auth: jwt-bearer(a signed assertion, which a Google service-account JSON satisfies directly) andauth: oauth-client-credentials. Both refresh in the background before expiry. path_base,token_urlandscopeprovider fields, which is what lets the above reach non-standard provider URLs without new code.
- The default worker-thread count is one per available core rather than a cap of four, so throughput
scales with the machine. It reads the node's core count and cannot see a CPU bandwidth quota, so on a
Kubernetes pod with a CPU limit it oversubscribes: pin
BUSBAR_WORKER_THREADSto your CPU limit there, or to1or2in a footprint-sensitive sidecar. - Memory now falls back toward idle after a burst instead of staying at the peak: a soak that plateaus around 1.2 GB drops to roughly 250 MB within 30 seconds of the load stopping. Windows builds keep the system allocator and do not get this.
- A cross-protocol stream whose backend reports usage in a trailing chunk now folds it into the terminal frame, so a non-OpenAI client receives real token counts instead of zeros. A Gemini JSON-array client on such a stream now receives one extra trailing element carrying that usage.
- Budgets shared across several nodes no longer clobber each other: the usage flush writes the delta since the last flush rather than an absolute value, so nodes sharing one store sum to the true total.
- The Valkey store's key deletion and credential writes are now atomic, so a partial failure cannot orphan
an upstream credential behind a deleted key. It also gains reconnect and
rediss://TLS. - The token endpoint an OAuth provider posts its client secret to was checked less strictly than the provider base URL, so a typo could send the secret to a cloud metadata address. Both self-minting clients now also refuse redirects and carry timeouts.
- Health probes were not re-spawned on config reload, so reloaded lanes went unprobed and each reload leaked probe tasks.
- A
scope:configured on ajwt-bearerprovider was ignored, and a mid-stream transport error billed the tokens accumulated before the cut. - A Cohere backend's pre-tool-call reasoning was dropped on any hop to another protocol, a raw-string tool argument was JSON-encoded twice by two writers, and an aborted Gemini JSON-array stream emitted two trailing error elements.
busbar --validatereported false errors on a config templating its URLs from environment variables, and missed a model whosecontext_maxconflicted across pools, so a clean validate could still fail at startup.- A config still carrying the removed
auth.mode:key now fails with a hint naming its replacement.
busbar --validatechecks a config file without booting or binding a socket, thenginx -tworkflow, and runs in CI without the runtime environment present.
BUSBAR_WORKER_THREADScaps the worker pool, which lowers memory on many-core hosts.
- A slow fire-and-forget hook could grow in-flight work without bound; those spawns are now capped and over-cap notifications dropped and counted.
- An unreadable config overlay file was overwritten rather than refused, which could silently discard persisted admin state.
- A queued request rewrite that could not be re-applied on failover forwarded the original un-rewritten body; the request is now rejected.
- The outbound guard now also blocks the Azure and Oracle Cloud metadata addresses, and the host Busbar signs for can no longer differ from the host it dials.
Maintenance release: CI fixes and dependency bumps only, no change in behaviour.
Maintenance release: no change in behaviour.
The API release: anything you could previously do only by editing YAML and restarting, you can now do over an authenticated, audited API. Hooks and policies are configured differently, so existing configs need a one-time update: see the 1.2.x to 1.3 migration guide. An old-form key reports a startup error naming exactly what to write instead.
- The management API moved under
/api/v1/admin/. The key endpoints at/admin/keys*are now/api/v1/admin/keys*; scripts calling the old paths need a one-line URL update. - A network-exposed admin listener refuses to start without client-certificate mTLS. Set
admin_tls.client_ca_file, keep admin on loopback, or waive it withadmin_insecure: trueif a mesh terminates mTLS for you. - The inline
policy:block and transport-namedroute:values. A pool'sroute:now takes a hook name or a built-in policy name (weighted,cheapest,fastest,least_busy,usage). Each removed key reports a startup error with its exact replacement. - The embedded Rhai script routing policy (
route: script), deprecated in 1.2.1, is gone. A compiled hook over a socket or an HTTP webhook does the same job with real process isolation.
- The admin API is a full config plane: read the running config, apply a validated change atomically, roll back to a previous version, register hooks, adjust pools, budgets and rate limits. Drive Busbar from Terraform, Ansible or CI with no SSH and no restarts. See the admin API reference.
- The admin API is on its own listener, always, with its own TLS and optional client-certificate mTLS, so the control plane binds and is firewalled independently of public traffic. It defaults to loopback.
- Admin credentials are scoped (read-only, hooks-register, full) rather than one shared token, every mutation is audited against whoever made it, and the admin auth chain is live-mutable with a guard refusing a change that would lock the caller out.
- Gates, taps and the restrict verb. A gate can reject a request or restrict which pool members may serve it, which is how data residency or a BAA-only lane is expressed without teaching the router about compliance. A tap observes and can never delay or fail a request. A request's hooks fire at once, so added latency is the slowest hook, not the sum. See the hooks guide.
- The rewrite verb: a trusted gate can replace the request body before dispatch, for context compression or redaction, across every protocol at once. A malformed or slow rewrite proceeds with the original body, so a broken compressor cannot corrupt a request.
- Hooks are defined once under
hooks:and referenced by name in a pool'shooks:list or inglobal_hooks:. One list carries both the ranking strategy and any gates. - Hook settings can be pushed to a running hook over the admin API, committing only when the hook
acknowledges, and a restarted hook gets its current settings before any traffic. Its observed settings
and metrics are readable at
GET /api/v1/admin/hooks/{name}/status. - Per-model and per-key metering at
GET /api/v1/admin/usage, reporting the raw token split in daily buckets with spend derived at read time, so a consumer with negotiated pricing can reconstruct cost. - Authentication is a chain of modules, each identifying the caller, rejecting, or passing to the next.
Token auth is the first module and is removable.
group_map:maps identity-provider groups to admin scope and data-plane access in one place, with per-module caps bounding what any module may assert. - API-applied changes persist to a Busbar-owned overlay file; your hand-written
config.yamlis never touched, so "who set this" is always answerable. POST /api/v1/admin/config/reloadapplies your config files atomically. Lane health is carried across by model identity rather than list position, so reordering or adding a model never resets what Busbar has learned, and that state now survives a restart.--safe-modeboots from your base config alone when an API-applied change is the problem.
- A routing hook can run as a compiled binary on a local Unix socket rather than over HTTP, deciding in roughly 8 microseconds. Same wire contract as the webhook. You run the hook process; Busbar connects lazily and reconnects across restarts, and requests keep flowing on the pool's fallback if you kill it.
- A hook can reject a request outright, and the caller gets an error in its own dialect. With the prompt payload below, this is the content-screening primitive: a hook that sees content can stop a request before it leaves your network.
- Two per-pool opt-ins extend the hook payload, both off by default:
policy.send_promptadds the prompt content so a trusted hook can screen for PII, andpolicy.send_useradds caller identity so a hook can route by who is asking. The caller's own credential is never in the payload. Each candidate also carries thetagsyou declared on it.
- The default hook deadline is 1 ms, down from 150, because a co-located socket hook decides in about 8
microseconds. Raise
policy.timeout_msif your hook does I/O; on timeout the decision falls back peron_errorand the request proceeds either way.
route: script(the embedded Rhai interpreter) works behind a build flag but warns at startup. Migrate to a compiled socket hook or an HTTP webhook. Removed in 1.3.0.
Busbar now carries more than chat. Embeddings, moderations, image generation, audio and rerank all translate across protocols the way chat already did, so a client in one dialect can reach a backend in another and get its answer back in its own dialect, errors included. Chat itself is byte-for-byte unchanged.
- Embeddings, routable to OpenAI, Amazon Bedrock, Cohere or Google Gemini from any dialect, with vectors, usage accounting and errors surviving the hop.
- Moderations, image generation (OpenAI, Gemini, Bedrock), audio transcription and speech (OpenAI, Gemini), and rerank (Cohere and Bedrock), all cross-protocol. A backend that lacks an operation answers with a clean 404 in the caller's dialect rather than a crash or malformed body.
attempt_timeout_mscatches a provider that fails by hanging, where the connection opens and headers never arrive, silently eating the whole failover budget on one member. Set it on a model and override per pool member. It covers connect and headers only, so it never cuts off a stream that has started.- Per-token log probabilities cross the OpenAI and Gemini seam both ways, buffered and streaming.
- The reasoning and thinking budget translates between the protocols that model it, gated by an operator
flag: set
reasoning: trueon a model to declare the backend accepts thinking parameters. Without it the ask is dropped with a warning, so a non-reasoning model can never fail because of translation. - An end-user identifier and the parallel-tool-calls switch now translate between OpenAI and Anthropic.
- Busbar is licensed under the Apache License 2.0 from this release onward: permissive, commercial-friendly, with an explicit patent grant.
- Error envelopes come back in the caller's own dialect, and usage accounting survives a cross-protocol round trip on every operation, not just chat.
- A Gemini backend's streamed reasoning was concatenated into the visible reply for every client in another dialect.
GET /v1/modelsandGET /v1beta/modelslist every routable name in the caller's dialect. This is the first call SDKs and self-hosted UIs make to build a model picker, and it previously returned 404. A key restricted byallowed_poolssees only what it may reach.
/metricswas empty until the first request arrived, so a freshly booted gateway exposed nothing to Prometheus, and direct model lanes with no pool were missing their health gauge./statsoutput and lane ordering are now stable across restarts, so scrapes and dashboards are reproducible.
upstream_modelseparates a model's config key from the id sent on the wire, which lets the same model sit behind two providers in one failover pool, for example Claude via both Anthropic and Bedrock. Contributed by @lguzzon.
A hardened maintenance release, functionally identical to 1.0.0.
- Releases ship a CycloneDX SBOM and a build-provenance attestation, so an artifact can be verified with
gh attestation verify <file> --repo GetBusbar/busbar. Dependencies are checked against the RustSec advisory database on every change and weekly.
First stable release. The HTTP API, configuration schema and wire-protocol contracts are stable under Semantic Versioning from here: no breaking change without a major version bump.
- Migration from rc.7:
governance.rate_sweep_intervalmust now be at least1; rc.7 silently disabled the sweep on0. No other change for a default deployment. - Structured output, stop reasons, image sources and redacted reasoning survive a cross-protocol hop intact rather than passing through as opaque blobs.
- Two Bedrock request shapes returned a 400 on a valid request, Anthropic cache markers were dropped on thinking and image blocks, and a streaming refusal could lose content.
- Billing corrections: sub-cent attribution, cancelled mid-stream requests, and no token billing for a stream aborted during translation.
- A client could hold a connection open indefinitely by trickling request headers on either listener.
Every request now takes one code path with billing metered from it, and the config surface is cleaned up to freeze a 1.0 contract. Same-protocol traffic stays byte-exact and just as fast.
auth.tokenis removed, andauth:,governance:andsecurity:reject unknown keys, so a stale or typo'd security key is a loud startup error rather than a silent default.
- A
limits:block puts every operational limit under operator control rather than hardcoding it: upstream timeout, request body maximum, idle connections per host, hard-down cooldown, upstream error body cap, TLS handshake timeout, honoredRetry-Afterceiling, defaultmax_tokens, and a newmax_inbound_concurrent. Each defaults to its previous value, so nothing changes unless you set it. - Grounding and web-search citations survive a cross-protocol hop, streaming and buffered.
observability.emit_server_timing(default off) emits theServer-Timing: busbarresponse header.
- Migration from rc.6. If
auth.token:was your only credential, move its value intoauth.client_tokens: [...]or the gateway refuses to start. Fix any typo'd or stale key underauth:,governance:orsecurity:, now hard startup errors. Update any script parsing the admin API error shape, now the same{"error":{"message","type"}}envelope as the proxy endpoints. Prefer the renamed keys:window_stowindow_secs, breakertrip.ntoconsecutive_n,failover.captomax_hops,failover.deadline_secstotimeout_secs; the old names still work, but do not set both spellings. - Cache-hit requests on Anthropic and Bedrock backends now bill more than in rc.6, because their cache tokens were previously not counted at all.
- Same-protocol traffic takes the same path as cross-protocol, with a short-circuit that re-emits the original bytes when nothing changed. Net effect is a fidelity improvement: most protocols now forward a same-protocol request byte for byte, where the old path re-serialized and reordered JSON keys.
- Streamed Responses requests reported zero tokens, so they were never billed.
- A Bedrock image sourced from S3 leaked its location as a corrupt payload when translated, and an internal redacted-reasoning marker could reach a client wire or be injected by one.
- A Gemini chunk carrying several citations produced one array event that crashed native Anthropic SDKs, and a corrupt Bedrock event prelude spliced raw bytes into the client stream.
- Admin key endpoints echoed a fragment of the request body, which carries the key secret, in a parse error.
observability.max_inflight_webhook_deliveries: 0silently dropped every delivery; it is floored at 1.
Server-Timing: busbar;dur=<ms>reports Busbar's own added latency on every response, readable in browser developer tools or any APM tool against your real traffic.- Provider-native features survive a cross-protocol hop instead of being silently dropped: sampling controls, structured output, reasoning and thinking blocks both ways, Anthropic cache markers against their Bedrock equivalent, cache-read token accounting, and Cohere image input. Where a target genuinely has no equivalent, the parameter is dropped with a warning rather than in silence.
- Cross-protocol translation of a large payload is roughly twice as fast (about 186 to 84 microseconds on a 32 KB body); small requests are unchanged at the per-request floor of about 33 microseconds. Full methodology at getbusbar.com/benchmark.
- The JSON serializer formats some floats differently, for example
1e26rather than1e+26. This is numerically lossless; only an exact string comparison on an exotic numeric passthrough field would notice.
- A small deeply-nested request body could crash the whole process, killing every in-flight request for every tenant. Bodies nested past 128 levels are now rejected before any value is constructed.
- Temperature clamped to a provider's native range is now reported with a warning rather than silently,
top_kspelling is preserved to Bedrock,max_completion_tokensis preserved for OpenAI reasoning models, andmax_tokens: 0is filtered uniformly. busbar_breaker_trips_totalcounted some trips twice and others not at all.- A JSON error is logged as a sanitized breadcrumb rather than the raw library message, which can embed fragments of the request body.
- Pluggable routing policies. A pool can declare
route:to order its members, feeding the existing failover loop so a policy can never strand a request. Built in:weighted(the default, unchanged),cheapest,fastest,least_busy, andusage, which steers away from members approaching a provider rate limit. Operator-defined logic runs over awebhooktransport in any language, honoring a per-pooltimeout_msand falling back peron_error, so it can never fail the client request. A pool that omitsroute:pays nothing for any of this. - Native inbound TLS and optional mutual TLS, without a reverse proxy. Add a
tls:block withcert_fileandkey_file, plusclient_ca_fileto require a client certificate, enforced at the handshake before any HTTP or token processing. Omittingtls:leaves the plain HTTP path unchanged. - Four Prometheus gauges refreshed at scrape time, not on the request path: per-key spend, per-key budget remaining, per-key tokens, and per-lane circuit-breaker state. Every label comes from your configuration.
- A pool member set to
weight: 0still received traffic carrying an existing session-affinity stickiness, so an operator draining a lane could not actually drain it. - Each incoming TLS handshake has a ten-second cap, so a client cannot park a connection before authenticating, and a routing webhook's response is capped at 64 KiB.
- A TLS certificate, key or CA that fails to load aborts startup naming the file; key material is never logged.
- The outbound guard now also blocks the Oracle Cloud metadata address.
- A lane that tripped could be benched permanently or have its recovery probe stolen. A clean stream end no longer records a spurious breaker failure, mid-stream error paths no longer double-record, and a failed recovery probe releases its permit instead of benching the lane for good.
- An upstream
Retry-Afteris honored as the breaker cooldown floor. - A large same-protocol response undercounted tokens, because usage past a scan cap was dropped.
- Outbound request guards closed a backslash-based bypass and a redirect vector on the telemetry exporter.
/metricsis no longer unconditionally open. It goes through the same authentication check as/stats, because the exposition discloses your lane and pool topology and error rates. Only/healthzstays open. Update any Prometheus scrape config that assumed otherwise.
- Every wire protocol is now first-class ingress. Previously clients could speak only Anthropic or OpenAI; now Responses, Cohere, Gemini and Bedrock clients can point their SDK's base URL at Busbar unmodified, with errors in the caller's native shape. See the protocols guide.
- Streamed assistant text from a Cohere backend was silently dropped on the read path.
- A Gemini response filtered for safety returned a spurious 500 instead of decoding normally, and an OpenAI stream with a usage-only trailing chunk produced a spurious extra event.
- A model named
adminwas reachable at the operator admin surface, making it unreachable to clients and bypassing per-model governance. That name is now rejected at config validation. - A host with a trailing dot, such as
127.0.0.1., slipped past the outbound metadata and IP checks and resolved to an internal target. - An Anthropic upstream request could carry two credential headers at once, a shape no native client produces.
- Cold start is roughly 30 times faster, about 206 ms down to 6 ms, so Busbar serves
/healthzin single-digit milliseconds, which is what a container readiness probe needs. In exchange,/metricsrenders empty for a moment after start and the few requests in that window are not counted.
First release candidate for 1.0: feature-complete and API-stable, with the remaining work being operational validation rather than features. The release binary shrank from about 12 MB to 7.4 MB with a faster hot path.
- An OpenAI-format request omitting
max_tokensfailed on every call against an Anthropic-backed lane, because Anthropic requires the field and OpenAI does not. Busbar now injects one at the translation boundary when the target protocol requires it, and a caller-supplied value is always preserved. The new per-modeldefault_max_tokenssets what gets injected, defaulting to 4096.
- Request bodies are now capped at 32 MiB. They were effectively unbounded, so a multi-gigabyte body could exhaust memory, most easily with authentication disabled.
- Token comparison is now proof against a compiler optimization that could reintroduce a timing signal.
- A
health:block written under a provider inconfig.yamlwas silently ignored, exactly as the shipped example documents it, so health probing never started for it.
- A single upstream 5xx could bench a single-member route for the full cooldown with no active recovery, because probing fired only for fully tripped lanes.
- Reasoning content from an OpenAI-dialect backend was dropped when translated to Anthropic.
--help,--versionand every startup misconfiguration print a clean error rather than a panic.
- Logs are now always emitted to stderr, with the level from
RUST_LOG. Previously every span and warning was dropped unless telemetry export was configured.
- Three hostile inputs could panic a worker: a malformed
Authorizationheader, an unbalanced brace in an upstream body, and an API key containing a control character. - A long-running lane spuriously tripped its breaker on clean recent traffic once old errors aged out.
- Concurrent selections could corrupt the weighted round-robin state and bias distribution across members.
- Session affinity used a randomly seeded hash, so sticky routing did not survive a restart, and cooldown jitter only ever lengthened cooldowns.
- Passthrough auth dropped the caller's own token and silently fell back to the lane's static key.
- Degraded routing skipped cross-protocol translation, so it was wrong whenever the chosen lane spoke a different protocol.
- The per-key rate-limit map never evicted stale windows, an unbounded memory leak.
/statsreported in-flight requests as always zero, and admin usage double-counted some responses.
- The admin token was compared with a non-constant-time comparison, a timing side channel that could let an attacker recover it byte by byte.
- Virtual-key generation now refuses to mint rather than falling back to a predictable, time-derived secret if the operating system random source is unavailable.
error_mapcan match a provider's structured error type, not just its numeric code, which is what some providers surface./statsreports each lane's client-fault counter alongside its success and error counts.
- A lane shared by several pools now carries independent circuit-breaker state per pool, so one pool's traffic tripping a lane no longer benches it for every other pool. A successful health probe recovers the lane everywhere, since it tests the one shared upstream. This supersedes the 0.15.0 note deferring it.
- Active health checks. A provider's
health.modecan benone(passive only, the default),dead(re-probe only tripped lanes so a recovered upstream is picked up promptly), oractive(probe every lane so a silently dead upstream trips before real traffic hits it). - A pool's
breaker:block now takes effect. It was parsed then ignored, with the breaker using a hardcoded rule.failover.exclusionsare likewise now enforced, andaffinity.header_nameis honored.
- A tripped lane never came back. Its recovery probe could succeed without ever closing the breaker, so any lane that tripped once became permanently dead.
This changelog begins at 0.14.0; earlier history is not recorded here.
- Cohere is now a supported wire protocol at
/v2/chat, including streaming, with system prompts preserved across a cross-protocol hop. - Azure OpenAI is reachable through a per-provider
auth: api-keystyle, shipped as a template inproviders.yaml.
- A pool landing on a member in another protocol returned a response with no
modelfield. - Token accounting was not charged on the buffered cross-protocol path, so per-key token limits never enforced there.
- The
max_requestslifetime cap was never enforced and per-lane success counts always read zero.
Project scaffolding for the open-source release. The project is licensed under the Apache License 2.0 as of 1.2.0.