Skip to main content
v2.0.0 hardens custom plugin loading against server-side request forgery (SSRF), closes a path that let an unauthenticated caller register a custom native plugin when dashboard auth is disabled or unconfigured, moves all governance APIs under a single /api/governance namespace, moves the plugin HTTPTransportPreHook phase to run after the transport authenticates the request, and restructures the request cost object into a per-category input/output/additional breakdown. This page covers the five breaking changes in this release and how to migrate.
Running Bifrost Enterprise? This page covers the OSS behavior only. See the Enterprise v2.0.0 Migration Guide for how these changes interact with SCIM-based authentication.

Breaking Change 1: Custom Plugin Downloads Are Now SSRF-Protected

Starting in 2.0.0-prerelease3, downloading a custom plugin binary (a path pointing at an http(s) URL) is hardened against server-side request forgery. What changed: plugin downloads no longer succeed if the URL resolves to a loopback, private (RFC 1918), CGNAT, link-local, or otherwise non-public address.
This applies to plugins defined in config.json too, not just ones added through the admin API. Every custom plugin path is re-verified on every server restart, regardless of whether it was configured via config.json or POST/PUT /api/plugins: there is no config-file exemption from the SSRF check.
Who is affected: deployments hosting a custom plugin .so on an internal artifact server, localhost, or any other private-network URL. How to fix it: add the internal host to the new deploy-time allowlist, server.plugin_download_private_allowlist in config.json. Entries can be hostnames, IP addresses, or CIDR ranges. Before (breaks after upgrading to 2.0.0-prerelease3 - the plugin’s path resolves to a private-network host, so DownloadPlugin now refuses it):
After (same plugin path; the allowlist entry is the only change):
This setting is deploy-time only: it is read from config.json/environment at server startup and cannot be changed through the plugin admin API. An invalid entry (not a valid hostname, IP, or CIDR) fails server startup with an error naming the entry.
Alternatively, mount the .so file into the container/host and reference it by local file path instead of a URL; local paths are unaffected by this change.
Custom LLM providers are not affected. This hardening applies only to downloading native plugin (.so) binaries via framework/plugins. Custom providers (an LLM endpoint registered with a custom base_url, e.g. a self-hosted or OpenAI-compatible server) use a separate, unmodified mechanism (the existing per-provider allow_private_network setting) and are untouched by this change or by server.plugin_download_private_allowlist.

Breaking Change 2: Custom Plugin Creation and Update Now Requires Admin Authentication

Starting in 2.0.0-prerelease3, creating or updating a custom-path plugin requires genuine admin authentication: it is no longer allowed through on a request that only passed because dashboard auth is disabled or unconfigured. Any of Bifrost’s supported admin authentication methods (Basic auth or a dashboard session) satisfies this; no particular method is required. What changed: POST /api/plugins and PUT /api/plugins/{name} now reject a request that sets a custom path if the caller reached the endpoint only because dashboard auth is disabled or unconfigured. Who is affected: deployments that run with dashboard auth disabled or unconfigured and manage custom-path plugins through the admin API in that mode. How to fix it: enable and configure dashboard auth, then authenticate as admin (Basic auth or a dashboard session, either is sufficient) before creating or updating a plugin with a custom path.
Only adding or updating a custom (path-based) plugin requires admin login. The auth check only runs when path is set on a non-built-in plugin: built-in plugins, and any plugin management that doesn’t touch path, are unaffected.
Plugins defined directly in config.json are not affected by this specific check. This auth requirement only runs inside the POST /api/plugins / PUT /api/plugins/{name} HTTP handlers. A plugin listed in config.json’s plugins array loads at server startup through a completely separate code path (loadCustomPlugins) that never calls those handlers: it loads the same way regardless of your dashboard auth configuration. (It is still subject to Breaking Change 1’s SSRF check above if its path is a URL.)

Breaking Change 3: Governance APIs Moved to the /api/governance Namespace

Governance APIs now use the same /api/governance/* namespace in Bifrost Open Source and Bifrost Enterprise. Shared resources have one path and one wire contract; Enterprise installs edition-specific handlers and adds relationship routes beneath the same resources. What changed: governance resources that were served from top-level paths (/api/teams, /api/users, /api/roles, /api/audit-logs, and others) moved under /api/governance. Team and User list endpoints also switched to limit/offset pagination on their canonical paths. Who is affected: any API client, script, Postman collection, or UI caller that talks to governance endpoints directly. How to fix it: move callers to the canonical paths in the mapping table below, and update Team/User list pagination parameters.

Compatibility window

Legacy Enterprise paths remain executable aliases for one complete GA release. Aliases execute the same handler directly, including mutating requests, and return:
  • Deprecation: true
  • Link: </api/governance/...>; rel="successor-version" (RFC 5829)
Alias use is also logged as structured telemetry. Migrate first-party and external clients during this window. The aliases are planned for removal in the following major release. To find alias traffic in your own clients, check responses for the presence of the Deprecation header and follow the successor-version link to the path you should call instead.
The former user-governance policy paths conflict with canonical User CRUD. They do not have compatibility aliases. Move POST /api/governance/users and PUT|DELETE /api/governance/users/{user_id} policy calls to /api/governance/users/{user_id}/governance when upgrading.

Endpoint mapping

Business Unit URLs were already under /api/governance/business-units; only OpenAPI path-parameter names were standardized.

Permissions

Canonical paths require the same RBAC resource and operation as the legacy alias they replace. Moving a client, script, or API key to a canonical path never requires regranting a permission, and no role loses access on upgrade. This parity is enforced by a test over the mapping table above, so it holds for every row. One deliberate exception: listing the virtual keys attached to a user is now gated on VirtualKeys rather than Users, on both the canonical /api/governance/users/{user_id}/virtual-keys and its legacy alias. This endpoint returns virtual-key material, and its siblings under /api/governance/virtual-keys/{vk_id}/users were already gated on VirtualKeys. Callers that read it with a Users-only role or API key need VirtualKeys:View added.
The user-level policy endpoints under /api/governance/users/{user_id}/governance keep the UserProvisioning resource they had when they lived on /api/governance/users. They have no compatibility alias, so grant parity here comes from the resource mapping rather than from an alias.

Pagination changes

Canonical Team and User list APIs use limit and zero-based offset, and return count, total_count, limit, and offset. Legacy aliases continue accepting page and preserve their former response envelopes during the compatibility window.
Audit logs are unchanged: /api/governance/audit-logs keeps the one-based page and limit parameters and the response envelope it served on /api/audit-logs. Only the path moved.

Team customer semantics

The canonical Team customer_id field retains the Open Source budget-hierarchy meaning. Enterprise many-to-many customer attachments use the relationship endpoints under /api/governance/teams/{team_id}/customers. Do not use the scalar customer_id field to represent Enterprise many-to-many membership.

Breaking Change 4: HTTPTransportPreHook Now Runs After Authentication

What changed: the plugin HTTP transport pipeline gained a phase. HTTPTransportPreAuthHook now runs before the transport’s authentication middlewares, and HTTPTransportPreHook — which used to hold that position — runs after them.
Who is affected: any custom plugin that writes a credential from HTTPTransportPreHook — a virtual key on x-bf-vk, an Authorization header, an x-api-key — typically to derive one from an upstream identity header.
Whether this breaks the plugin depends on what authenticates inference in your deployment, and neither outcome reports an error.
  • Authentication rejects the request — Enterprise with an identity provider configured and enforce_auth_on_inference enabled. Authentication runs first, so HTTPTransportPreHook never executes and the credential is never written.
  • Authentication permits the request — every Open Source deployment, because inference auth is a deliberate pass-through, and Enterprise without an identity provider. The hook still runs, and the header it writes is still visible to components downstream of it, so governance validates the key and the plugin keeps working.
The second case is why this can look deployment-specific: the same plugin binary keeps working on one gateway and stops on another. In both cases the credential is now written after the point that authenticates it, so move that work to HTTPTransportPreAuthHook — the only phase where a credential is guaranteed to be in place before authentication reads it.
How to fix it: rename the function. HTTPTransportPreAuthHook receives the same *HTTPRequest — headers, query, path params and body — and applies the same mutations, so nothing inside the hook body changes. Before (v1.x — credential injected from the pre-hook):
After (v2.0 — same body, new phase):
A plugin may export both hooks; they are independent phases. HTTPTransportPreAuthHook runs once per request. HTTPTransportPreHook runs once per request too, but only for requests that reach it: a pre-auth hook that short-circuits, or authentication rejecting the request, skips it. Plugins that do not touch credentials need no behavioural change — and gain something. Because authentication has already run, HTTPTransportPreHook now sees the resolved caller identity on ctx. They do still need the new method to compile, as the Note below explains. Two differences between the phases are worth knowing: A plugin that must observe every request, including rejected ones, should not rely on HTTPTransportPreHook for that bookkeeping.
HTTPTransportPreAuthHook is part of the HTTPTransportPlugin interface, so Go plugins compiled against v2.0 must define it. Plugins with nothing to do before authentication return (nil, nil). Native .so plugins are unaffected unless they opt in: the symbol is looked up optionally, and a plugin that never exports it is skipped by the phase.
See the Plugin Migration Guide for the full hook contract.

Breaking Change 5: Request Cost Is Now a Per-Category Breakdown

The BifrostCost object (returned as cost on inference responses and as token_usage.cost on log entries) was restructured from a flat list of token-category costs into three buckets (input, output, additional), each with an optional nested details object, that reconcile to total_cost. What changed: the flat fields (input_tokens_cost, output_tokens_cost, reasoning_tokens_cost, citation_tokens_cost, search_queries_cost, request_cost) were replaced by input_cost, output_cost, and additional_cost aggregates plus input_cost_details, output_cost_details, and additional_cost_details. When the category split is populated, input_cost + output_cost + additional_cost == total_cost. A cost parsed from the legacy bare-number shape is the exception: UnmarshalJSON sets only total_cost and leaves the category fields zero, so treat total_cost as authoritative and check the category fields before relying on the equality. Who is affected: any client that parses the cost object from an inference response or the logs API, any Go code that reads schemas.BifrostCost, and any custom framework/logstore implementation. How to fix it: remap the fields you read using the table below.

JSON field mapping

input_cost_details also breaks out audio_cost, image_cost, cached_read_cost, and cached_write_cost; output_cost_details also breaks out audio_cost and image_cost. Every details field is omitted when zero.
Reading historical data still works. BifrostCost accepts the legacy flat shape (and a bare float total) when deserializing, so cost objects stored by v1.x, and providers that still emit the flat shape, parse without error. Only newly emitted responses use the nested shape, so update any consumer that reads cost fields by name.
Before (v1.x response cost):
After (2.0.0 response cost):

Go consumers of schemas.BifrostCost

The struct fields were renamed to match the JSON above, and the former top-level breakouts now live on the details structs: Before:
After:

Custom log store implementations: BulkUpdateCost

LogStore.BulkUpdateCost now takes the per-category split instead of a bare total, so a cost recompute keeps the denormalized input_cost/output_cost/additional_cost columns reconciled with the cost column. Before:
After:
CostUpdate carries Total, Input, Output, and Additional. A BulkUpdateCost implementation reads these fields and writes them to the row (see framework/logstore/clickhousestore.go); it does not construct them. Construction happens in the caller that builds the update map: it turns a *schemas.BifrostCost into a CostUpdate with logstore.CostUpdateFromBreakdown(breakdown) (which attributes an unsplit total to the input side for opaque-total providers), or builds CostUpdate{Total: t, Input: t} directly when only a total is available.

Migration Checklist

1

Check for internally-hosted custom plugins

Look at every entry in config.json’s plugins list (or the equivalent admin-API-managed plugin configs) for a path that is an http:// or https:// URL pointing at a private, loopback, or otherwise internal address.
2

Allowlist internal plugin hosts, or switch to a local file path

For any internal URL found above, add the host (or its CIDR) to server.plugin_download_private_allowlist in config.json, or mount the binary locally and use a file path instead.
3

Confirm dashboard auth is configured before creating a custom-path plugin

If dashboard auth is disabled or unconfigured, POST /api/plugins and PUT /api/plugins/{name} will now reject any request that sets a custom path. Enable dashboard authentication first if you need to register a plugin with a custom binary path.
4

Move credential work in custom plugins to `HTTPTransportPreAuthHook`

Grep your plugins for writes to x-bf-vk, Authorization, x-api-key, x-goog-api-key, or api-key inside HTTPTransportPreHook. Any you find must move to HTTPTransportPreAuthHook — the rename is the whole migration, and the failure mode if you miss one is silent.
5

Move governance API callers to canonical `/api/governance` paths

Update API clients, scripts, Postman collections, and UI callers using the endpoint mapping. On Enterprise, upgrade so it installs its Team read handler before Open Source route registration.
6

Replace `page` with `offset` on canonical Team and User list requests

Canonical Team and User list endpoints use limit and zero-based offset. Audit logs are unchanged and keep one-based page.
7

Grant `VirtualKeys:View` where a user's virtual keys are read

Any role or API key that reads /api/governance/users/{user_id}/virtual-keys with only Users permission needs VirtualKeys:View added (see Permissions).
8

Monitor deprecated-route telemetry until alias traffic reaches zero

Legacy governance paths remain executable aliases for one complete GA release and return a Deprecation header. They are planned for removal in the following major release.
9

Update consumers of the request cost object

Any client parsing cost / token_usage.cost, or Go code reading schemas.BifrostCost, must move from the flat fields (input_tokens_cost, reasoning_tokens_cost, …) to the nested input/output/additional shape (see the JSON field mapping).
10

Update custom log store implementations for the new BulkUpdateCost signature

If you implement framework/logstore.LogStore, change BulkUpdateCost to take map[string]CostUpdate and read each value’s Total, Input, Output, and Additional. CostUpdateFromBreakdown belongs in the caller that builds the update map, not the implementation.