/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.
Breaking Change 1: Custom Plugin Downloads Are Now SSRF-Protected
Starting in2.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.
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..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):
path; the allowlist entry is the only change):
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..so file into the container/host and reference it by local file path instead of a URL; local paths are unaffected by this change.
.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 in2.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.
path is set on a non-built-in plugin: built-in plugins, and any plugin management that doesn’t touch path, are unaffected.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: trueLink: </api/governance/...>; rel="successor-version"(RFC 5829)
Deprecation header and follow the successor-version link to the path you should call instead.
Endpoint mapping
/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 onVirtualKeys 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.
/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 uselimit 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.
/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 Teamcustomer_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.
HTTPTransportPreHook — a virtual key on x-bf-vk, an Authorization header, an x-api-key — typically to derive one from an upstream identity header.
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):
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:
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.Breaking Change 5: Request Cost Is Now a Per-Category Breakdown
TheBifrostCost 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.
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.cost):
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:
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:
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
Check for internally-hosted custom plugins
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.Allowlist internal plugin hosts, or switch to a local file path
server.plugin_download_private_allowlist in config.json, or mount the binary locally and use a file path instead.Confirm dashboard auth is configured before creating a custom-path plugin
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.Move credential work in custom plugins to `HTTPTransportPreAuthHook`
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.Move governance API callers to canonical `/api/governance` paths
Replace `page` with `offset` on canonical Team and User list requests
limit and zero-based offset. Audit logs are unchanged and keep one-based page.Grant `VirtualKeys:View` where a user's virtual keys are read
/api/governance/users/{user_id}/virtual-keys with only Users permission needs VirtualKeys:View added (see Permissions).Monitor deprecated-route telemetry until alias traffic reaches zero
Deprecation header. They are planned for removal in the following major release.Update consumers of the request cost object
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).Update custom log store implementations for the new BulkUpdateCost signature
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.
