Compare commits

..

12 Commits

48 changed files with 1771 additions and 2432 deletions

View File

@@ -1,20 +1,27 @@
package server
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
const statusCoalesceWindow = 200 * time.Millisecond
// SubscribeStatus pushes a fresh StatusResponse on every connection state
// change. The first message is the current snapshot, so a re-subscribing
// client doesn't need to also call Status. Subsequent messages fire when
// the peer recorder reports any of: connected/disconnected/connecting,
// management or signal flip, address change, or peers list change.
//
// The change channel coalesces bursts to a single tick. If the consumer
// is slow the daemon drops extras (not blocks), and the next snapshot
// the consumer pulls already reflects everything.
// Bursts are coalesced deterministically: the first tick after a quiet
// period is sent immediately, then a short window swallows the rest of the
// burst and a single trailing snapshot covers whatever arrived meanwhile.
// Every send is a full snapshot of the recorder's current state, so
// swallowed ticks lose no information.
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
subID, ch := s.statusRecorder.SubscribeToStateChanges()
defer func() {
@@ -37,12 +44,50 @@ func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonSe
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
pending, open := collectStatusBurst(stream.Context(), ch)
if pending {
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
}
if !open {
return nil
}
case <-stream.Context().Done():
return nil
}
}
}
// collectStatusBurst waits out the coalesce window, absorbing further ticks.
// pending reports whether any tick arrived; open is false when the channel
// closed or the stream context ended.
func collectStatusBurst(ctx context.Context, ch <-chan struct{}) (pending, open bool) {
timer := time.NewTimer(statusCoalesceWindow)
defer timer.Stop()
for {
select {
case _, ok := <-ch:
if !ok {
return pending, false
}
pending = true
case <-timer.C:
select {
case _, ok := <-ch:
if !ok {
return pending, false
}
pending = true
default:
}
return pending, true
case <-ctx.Done():
return false, false
}
}
}
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
resp, err := s.buildStatusResponse(stream.Context(), req)
if err != nil {

View File

@@ -103,6 +103,18 @@ func main() {
updaterHolder := updater.NewHolder(app.Event)
update := services.NewUpdate(conn, updaterHolder)
daemonFeed := services.NewDaemonFeed(conn, app.Event, updaterHolder, debugLog)
// Status snapshots go only to visible windows — a snapshot is full state,
// so a hidden webview loses nothing by being skipped; it gets the cached
// one on show (SetShowReplay below). Every other event stays on the bus.
daemonFeed.SetWindowDispatcher(func(st services.Status) {
ev := &application.CustomEvent{Name: services.EventStatusSnapshot, Data: st}
for _, w := range app.Window.GetAll() {
if w == nil || !w.IsVisible() {
continue
}
w.DispatchWailsEvent(ev)
}
})
notifier := notifications.New()
compat := services.NewCompat(conn)
// macOS shows no toast until permission is requested. Run it after
@@ -152,8 +164,31 @@ func main() {
// re-centering on that environment; nil leaves placement to the WM on full
// desktops, macOS, and Windows.
windowManager.SetRecenterOnShow(recenterOnShowPredicate())
// Replay the latest snapshot into a window on (re)show, so a webview that
// was hidden while pushes flowed never paints stale state. ReplayLast keeps
// the feed's status lock across the dispatch, so a racing live push can't
// slip in between and then be overwritten by this older cached snapshot.
windowManager.SetShowReplay(func(w application.Window) {
daemonFeed.ReplayLast(func(st services.Status) {
w.DispatchWailsEvent(&application.CustomEvent{Name: services.EventStatusSnapshot, Data: st})
})
})
app.RegisterService(application.NewService(windowManager))
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
windowManager.ShowMain()
})
}
// Welcome window, first launch only — Continue flips OnboardingCompleted
// so later launches skip it. ApplicationStarted hook so the Wails window
// machinery is fully up before the window is created.
@@ -377,20 +412,5 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
window.Hide()
})
// On macOS, Wails' default applicationShouldHandleReopen handler Show()s
// every hidden window on dock-icon click, resurrecting hide-on-close
// surfaces like Settings. Cancel it in a hook (hooks run before listeners)
// and show only the main window. No-op elsewhere — the event never fires.
if runtime.GOOS == "darwin" {
app.Event.RegisterApplicationEventHook(events.Mac.ApplicationShouldHandleReopen, func(e *application.ApplicationEvent) {
e.Cancel()
if e.Context().HasVisibleWindows() {
return
}
window.Show()
window.Focus()
})
}
return window
}

View File

@@ -5,6 +5,7 @@ package services
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
@@ -167,6 +168,14 @@ type DaemonFeed struct {
cancel context.CancelFunc
streamWg sync.WaitGroup
// statusSubs are Go-side snapshot consumers (the tray), fed directly so
// they don't ride the window event bus. Callbacks run synchronously on
// the stream goroutine, so pushes arrive in order.
statusSubsMu sync.Mutex
statusSubs []func(Status)
lastStatus *Status
windowDispatcher func(Status)
switchMu sync.Mutex
switchInProgress bool
switchInProgressUntil time.Time
@@ -188,6 +197,55 @@ func NewDaemonFeed(conn DaemonConn, emitter Emitter, updaterHolder *updater.Hold
return &DaemonFeed{conn: conn, emitter: emitter, updater: updaterHolder, logCtl: logCtl}
}
// OnStatus registers a Go-side status subscriber. Not for the frontend —
// React consumers subscribe to EventStatusSnapshot on the event bus.
func (s *DaemonFeed) OnStatus(cb func(Status)) {
s.statusSubsMu.Lock()
s.statusSubs = append(s.statusSubs, cb)
s.statusSubsMu.Unlock()
}
// SetWindowDispatcher installs the frontend push path: it receives every
// snapshot and decides which webview windows get it (visible ones). While
// unset, pushStatus falls back to the event-bus broadcast.
func (s *DaemonFeed) SetWindowDispatcher(fn func(Status)) {
s.statusSubsMu.Lock()
s.windowDispatcher = fn
s.statusSubsMu.Unlock()
}
// pushStatus delivers a snapshot to the Go-side subscribers and the frontend,
// and caches it for ReplayLast. Hidden windows are skipped by the
// window dispatcher; they catch up via the show replay (WindowManager).
func (s *DaemonFeed) pushStatus(st Status) {
s.statusSubsMu.Lock()
s.lastStatus = &st
subs := slices.Clone(s.statusSubs)
dispatch := s.windowDispatcher
s.statusSubsMu.Unlock()
for _, cb := range subs {
cb(st)
}
if dispatch != nil {
dispatch(st)
return
}
s.emitter.Emit(EventStatusSnapshot, st)
}
// ReplayLast feeds the most recently pushed snapshot to dispatch; no-op before
// the first push. The status lock is held across the dispatch so a concurrent
// pushStatus cannot deliver a newer snapshot in between the read and the
// dispatch — the replayed value is never older than anything already delivered.
func (s *DaemonFeed) ReplayLast(dispatch func(Status)) {
s.statusSubsMu.Lock()
defer s.statusSubsMu.Unlock()
if s.lastStatus == nil {
return
}
dispatch(*s.lastStatus)
}
// BeginProfileSwitch arms suppression for a switch from Connected/Connecting,
// where the daemon emits stale Connected updates during Down's teardown then an
// Idle before the new Up; statusStreamLoop drops those, and a synthetic
@@ -201,7 +259,7 @@ func (s *DaemonFeed) BeginProfileSwitch() {
s.switchLoginWatch = true
s.switchLoginWatchUntil = now.Add(30 * time.Second)
s.switchMu.Unlock()
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusConnecting})
s.pushStatus(Status{Status: StatusConnecting})
}
// CancelProfileSwitch aborts a switch midway (tray Disconnect while Connecting):
@@ -343,7 +401,7 @@ func (s *DaemonFeed) statusStreamLoop(ctx context.Context) {
return
}
unavailable = true
s.emitter.Emit(EventStatusSnapshot, Status{Status: StatusDaemonUnavailable})
s.pushStatus(Status{Status: StatusDaemonUnavailable})
}
op := func() error {
@@ -403,7 +461,7 @@ func (s *DaemonFeed) emitStatus(st Status) {
log.Debugf("suppressing status=%q during profile switch", st.Status)
return
}
s.emitter.Emit(EventStatusSnapshot, st)
s.pushStatus(st)
if triggerLogin {
s.emitter.Emit(EventTriggerLogin)
}

View File

@@ -115,6 +115,9 @@ type WindowManager struct {
// recenterOnShow is set only on the minimal-WM/XEmbed path, where the WM neither centers nor
// restores position; nil on full desktops so re-centering can't fight a user-moved window.
recenterOnShow func() bool
// showReplay fires whenever a live-but-hidden window is (re)shown, so the
// caller can replay the latest status snapshot into its webview.
showReplay func(application.Window)
}
// NewWindowManager wires the manager to the main app; translator/prefs may be nil (tests). The
@@ -174,6 +177,7 @@ func (s *WindowManager) OpenSettings(tab string) {
s.app.Event.Emit(EventSettingsOpen, target)
s.settings.Show()
s.settings.Focus()
s.notifyShown(s.settings)
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.settings)
}
@@ -220,6 +224,7 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
s.centerOnCursorScreen(s.browserLogin)
s.browserLogin.Show()
s.browserLogin.Focus()
s.notifyShown(s.browserLogin)
}
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
@@ -280,6 +285,7 @@ func (s *WindowManager) OpenSessionExpiration(seconds int) {
s.centerOnCursorScreen(s.sessionExpiration)
s.sessionExpiration.Show()
s.sessionExpiration.Focus()
s.notifyShown(s.sessionExpiration)
}
func (s *WindowManager) CloseSessionExpiration() {
@@ -347,6 +353,7 @@ func (s *WindowManager) OpenInstallProgress(version string) {
s.installProgress.SetURL(startURL)
s.installProgress.Show()
s.installProgress.Focus()
s.notifyShown(s.installProgress)
s.centerWhenReady(s.installProgress)
}
@@ -380,6 +387,7 @@ func (s *WindowManager) OpenWelcome() {
}
s.welcome.Show()
s.welcome.Focus()
s.notifyShown(s.welcome)
s.centerWhenReady(s.welcome)
}
@@ -417,6 +425,7 @@ func (s *WindowManager) OpenError(title, message string) {
s.errorDialog.SetURL(startURL)
s.errorDialog.Show()
s.errorDialog.Focus()
s.notifyShown(s.errorDialog)
s.centerWhenReady(s.errorDialog)
}
@@ -443,6 +452,7 @@ func (s *WindowManager) ShowMain() {
}
s.mainWindow.Show()
s.mainWindow.Focus()
s.notifyShown(s.mainWindow)
// Re-center (minimal-WM only; see centerWhenReady).
s.centerWhenReady(s.mainWindow)
}
@@ -452,6 +462,17 @@ func (s *WindowManager) SetRecenterOnShow(pred func() bool) {
s.recenterOnShow = pred
}
// SetShowReplay installs the shown-window hook (see the showReplay field).
func (s *WindowManager) SetShowReplay(fn func(application.Window)) {
s.showReplay = fn
}
func (s *WindowManager) notifyShown(w application.Window) {
if s.showReplay != nil && w != nil {
s.showReplay(w)
}
}
// centerWhenReady centers w only on minimal WMs (recenterOnShow); elsewhere it
// returns so it never fights a user-moved window. On GTK4 an inline Center()
// no-ops until the GdkSurface is realized (async, after Show) and InvokeAsync
@@ -572,6 +593,7 @@ func (s *WindowManager) restoreHiddenWindowsLocked() {
continue
}
w.Show()
s.notifyShown(w)
if w == s.mainWindow {
mainRestored = true
}

View File

@@ -67,9 +67,8 @@ type Tray struct {
loc *Localizer
// menu and the *Item/*Submenu fields below are reassigned by buildMenu
// on every relayout — touch them only with menuMu held. Exceptions:
// the Connect/Disconnect OnClick closures capture their own item, and
// refreshSessionExpiresLabel snapshots its item under menuMu.
// on every relayout, which destroys the replaced tree — locate items and
// call them with menuMu held, so a relayout can't destroy one mid-call.
menu *application.Menu
statusItem *application.MenuItem
// sessionExpiresItem shows the SSO deadline as a remaining-time label,
@@ -172,7 +171,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// in the right locale — no English flash then re-paint.
loc: svc.Localizer,
}
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() })
t.updater = newTrayUpdater(app, window, svc.Update, svc.Notifier, t.loc, func() { t.applyIcon() }, func() { t.relayoutMenu() }, func() { t.showMainWindow() })
t.tray = app.SystemTray.New()
// Seed panel-theme detection before the first paint so the initial icon
// matches the panel's light/dark scheme (Linux only).
@@ -196,7 +195,7 @@ func NewTray(app *application.App, window *application.WebviewWindow, svc TraySe
// menu (e.g. GNOME Shell AppIndicator).
bindTrayClick(t)
app.Event.On(services.EventStatusSnapshot, t.onStatusEvent)
svc.DaemonFeed.OnStatus(t.applyStatus)
app.Event.On(services.EventDaemonNotification, t.onSystemEvent)
// Refresh the Profiles submenu on ProfileSwitcher's change event. A
// switch on an idle daemon drives no status transition, so without this
@@ -253,6 +252,19 @@ func (t *Tray) ShowWindow() {
t.window.Focus()
}
// showMainWindow brings the main window forward through the WindowManager so
// the status replay and re-centering apply; falls back to a bare Show in tests.
func (t *Tray) showMainWindow() {
if t.svc.WindowManager != nil {
t.svc.WindowManager.ShowMain()
return
}
if t.window != nil {
t.window.Show()
t.window.Focus()
}
}
// applyLanguage re-renders every translated surface in the Localizer's current
// language. Wails dispatches menu/tray APIs onto the UI thread internally, so
// calling them from the Localizer's background goroutine is safe; profileLoadMu
@@ -286,6 +298,7 @@ func (t *Tray) relayoutMenu() {
t.menuMu.Lock()
defer t.menuMu.Unlock()
old := t.menu
t.menu = t.buildMenu()
t.statusMu.Lock()
@@ -356,6 +369,10 @@ func (t *Tray) relayoutMenu() {
// Single push of the whole tree: on Linux one LayoutUpdated with fresh
// container ids; on darwin an NSMenu rebuild against the cached pointer.
t.tray.SetMenu(t.menu)
if old != nil {
old.Destroy()
}
}
func (t *Tray) buildMenu() *application.Menu {
@@ -371,13 +388,11 @@ func (t *Tray) buildMenu() *application.Menu {
menu.AddSeparator()
// The OnClick closures capture the local item because t.upItem/t.downItem
// are menuMu-guarded and must not be read from the click goroutine.
upItem := menu.Add(t.loc.T("tray.menu.connect"))
upItem.OnClick(func(*application.Context) { t.handleConnect(upItem) })
upItem.OnClick(func(*application.Context) { t.handleConnect() })
t.upItem = upItem
downItem := menu.Add(t.loc.T("tray.menu.disconnect"))
downItem.OnClick(func(*application.Context) { t.handleDisconnect(downItem) })
downItem.OnClick(func(*application.Context) { t.handleDisconnect() })
downItem.SetHidden(true)
t.downItem = downItem
@@ -469,9 +484,7 @@ func (t *Tray) handleQuit() {
t.app.Quit()
}
// handleConnect receives the clicked item from the buildMenu closure —
// t.upItem is menuMu-guarded and must not be read here.
func (t *Tray) handleConnect(upItem *application.MenuItem) {
func (t *Tray) handleConnect() {
// NeedsLogin/SessionExpired/LoginFailed won't honor a plain Up RPC — they
// need the Login → WaitSSOLogin → Up sequence. Emit EventTriggerLogin so
// the React startLogin() (which owns the BrowserLogin popup) drives it;
@@ -485,7 +498,7 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
t.app.Event.Emit(services.EventTriggerLogin)
return
}
upItem.SetEnabled(false)
t.setItemEnabled(func() *application.MenuItem { return t.upItem }, false)
// Arm the SSO auto-handoff: Up() is async and the daemon may flip to
// NeedsLogin on an SSO peer with no cached token. applyStatus consumes the
// flag on that transition to trigger browser-login without a second Connect
@@ -500,18 +513,25 @@ func (t *Tray) handleConnect(upItem *application.MenuItem) {
t.statusMu.Lock()
t.pendingConnectLogin = false
t.statusMu.Unlock()
upItem.SetEnabled(true)
t.setItemEnabled(func() *application.MenuItem { return t.upItem }, true)
}
}()
}
func (t *Tray) setItemEnabled(get func() *application.MenuItem, enabled bool) {
t.menuMu.Lock()
defer t.menuMu.Unlock()
if item := get(); item != nil {
item.SetEnabled(enabled)
}
}
// handleDisconnect aborts any in-flight profile switch before sending Down —
// otherwise the switcher's queued Up would reconnect right after, making the
// click a no-op. Also clears Peers' optimistic-Connecting guard so the daemon's
// Idle push paints through instead of being swallowed by the suppression filter.
// Receives the clicked item from the buildMenu closure (see handleConnect).
func (t *Tray) handleDisconnect(downItem *application.MenuItem) {
downItem.SetEnabled(false)
func (t *Tray) handleDisconnect() {
t.setItemEnabled(func() *application.MenuItem { return t.downItem }, false)
t.profileMu.Lock()
if t.switchCancel != nil {
t.switchCancel()
@@ -523,7 +543,7 @@ func (t *Tray) handleDisconnect(downItem *application.MenuItem) {
if err := t.svc.Connection.Down(context.Background()); err != nil {
log.Errorf("disconnect: %v", err)
t.notifyError(t.loc.T("notify.error.disconnect"))
downItem.SetEnabled(true)
t.setItemEnabled(func() *application.MenuItem { return t.downItem }, true)
}
}()
}

View File

@@ -5,6 +5,7 @@ package main
import (
"context"
"fmt"
"slices"
"sort"
log "github.com/sirupsen/logrus"
@@ -59,10 +60,11 @@ func (t *Tray) loadConfig() {
t.profileMu.Unlock()
}
// loadProfiles fetches the profile list and relayouts the menu. Also called
// from applyStatus to catch flips from another channel (CLI, autoconnect),
// since the daemon emits no active-profile event. Full relayout (not
// Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's doc comment.
// loadProfiles fetches the profile list and relayouts the menu when the rows
// changed. Also called from applyStatus to catch flips from another channel
// (CLI, autoconnect), since the daemon emits no active-profile event. Full
// relayout (not Clear()+Add()) is required for KDE/Plasma — see relayoutMenu's
// doc comment.
func (t *Tray) loadProfiles() {
t.profileLoadMu.Lock()
defer t.profileLoadMu.Unlock()
@@ -80,11 +82,14 @@ func (t *Tray) loadProfiles() {
}
t.profilesMu.Lock()
changed := username != t.profilesUser || !slices.Equal(profiles, t.profiles)
t.profiles = profiles
t.profilesUser = username
t.profilesMu.Unlock()
t.relayoutMenu()
if changed {
t.relayoutMenu()
}
}
// fillProfileSubmenu paints cached profile rows into the freshly built submenu.

View File

@@ -30,11 +30,11 @@ const (
// handleSessionExpired notifies and brings the window forward so the frontend's /login route drives renewal.
func (t *Tray) handleSessionExpired() {
t.notify(t.loc.T("notify.sessionExpired.title"), t.loc.T("notify.sessionExpired.body"), notifyIDSessionExpired)
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
if t.window == nil {
return
}
t.window.SetURL("/#/login")
t.showMainWindow()
}
// applySessionExpiry refreshes the cached SSO deadline and reports whether it changed.
@@ -104,21 +104,19 @@ func sessionRefreshInterval(remaining time.Duration) time.Duration {
}
// refreshSessionExpiresLabel updates only the countdown label, no relayout, to avoid disturbing an open menu.
// The item is snapshotted under menuMu since buildMenu reassigns it on every relayout.
// menuMu is held across the SetLabel so a relayout can't destroy the item mid-call.
func (t *Tray) refreshSessionExpiresLabel() {
t.menuMu.Lock()
item := t.sessionExpiresItem
t.menuMu.Unlock()
if item == nil {
return
}
t.sessionMu.Lock()
deadline := t.sessionExpiresAt
t.sessionMu.Unlock()
if deadline.IsZero() {
return
}
item.SetLabel(t.sessionRowLabel(deadline))
t.menuMu.Lock()
defer t.menuMu.Unlock()
if t.sessionExpiresItem != nil {
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(deadline))
}
}
func (t *Tray) sessionRowLabel(deadline time.Time) string {
@@ -310,8 +308,7 @@ func (t *Tray) openSessionExtendFlow() {
if seconds <= 0 {
if t.window != nil {
t.window.SetURL("/#/login")
t.window.Show()
t.window.Focus()
t.showMainWindow()
}
return
}

View File

@@ -5,19 +5,9 @@ package main
import (
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/netbirdio/netbird/client/ui/services"
)
func (t *Tray) onStatusEvent(ev *application.CustomEvent) {
st, ok := ev.Data.(services.Status)
if !ok {
return
}
t.applyStatus(st)
}
// applyStatus repaints the tray from a daemon snapshot. Icon refresh is skipped
// when no icon-relevant input changed: the daemon emits rapid SubscribeStatus
// bursts during health probes that would otherwise spam Shell_NotifyIcon.

View File

@@ -28,6 +28,9 @@ type trayUpdater struct {
// About submenu, which KDE/Plasma caches on first open and never re-fetches
// on a plain SetLabel/SetHidden — only a relayout (fresh submenu ids) repaints.
onMenuChange func()
// showMain brings the main window forward via the WindowManager (status
// replay + centering); nil falls back to a bare window.Show.
showMain func()
mu sync.Mutex
item *application.MenuItem
@@ -36,7 +39,7 @@ type trayUpdater struct {
progressWindowOpen bool
}
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange func(), onMenuChange func()) *trayUpdater {
func newTrayUpdater(app *application.App, window *application.WebviewWindow, update *services.Update, notifier *notifications.NotificationService, loc *Localizer, onIconChange, onMenuChange, showMain func()) *trayUpdater {
u := &trayUpdater{
app: app,
window: window,
@@ -45,6 +48,7 @@ func newTrayUpdater(app *application.App, window *application.WebviewWindow, upd
loc: loc,
onIconChange: onIconChange,
onMenuChange: onMenuChange,
showMain: showMain,
}
app.Event.On(updater.EventStateChanged, u.onStateEvent)
// Seed from cached state to cover an event that fired before wiring completed.
@@ -193,6 +197,10 @@ func (u *trayUpdater) openProgressWindow(version string) {
url += "?version=" + version
}
u.window.SetURL(url)
if u.showMain != nil {
u.showMain()
return
}
u.window.Show()
u.window.Focus()
}

View File

@@ -23,7 +23,6 @@ import (
"github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/formatter/hook"
agentnetworkpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/management/internals/server"
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
@@ -113,23 +112,6 @@ var (
mgmtSingleAccModeDomain = ""
}
// Load the management-side LLM pricing defaults file: an
// explicitly configured path is required to load (a typo must
// fail startup — the operator believes those rates are live);
// otherwise <datadir>/defaults_llm_pricing.yaml is probed and
// may be absent (compiled-in defaults serve). Either way the
// path stays watched: the reloader picks up edits — and the
// file appearing later — without a restart.
pricingPath := config.AgentNetwork.PricingDefaultsFile
pricingRequired := pricingPath != ""
if !pricingRequired {
pricingPath = filepath.Join(config.Datadir, agentnetworkpricing.DefaultFileName)
}
if err := agentnetworkpricing.LoadFile(pricingPath, pricingRequired); err != nil {
return fmt.Errorf("load agent-network pricing defaults: %v", err)
}
agentnetworkpricing.StartReloader(ctx, agentnetworkpricing.ReloadInterval)
srv := newServer(&server.Config{
NbConfig: config,
DNSDomain: dnsDomain,

View File

@@ -7,28 +7,12 @@ package catalog
import "github.com/netbirdio/netbird/shared/management/http/api"
// Model is the in-memory representation of a catalog model.
//
// The three cache rates mirror the proxy cost meter's Entry semantics
// (USD per 1k tokens; 0 = no rate configured, that bucket bills at
// InputPer1k):
// - CachedInputPer1k: OpenAI-shape rate for cached prompt tokens
// (a SUBSET of input tokens). Typically 0.1-0.5x input.
// - CacheReadPer1k / CacheCreationPer1k: Anthropic-shape rates for
// the two ADDITIVE prompt-cache buckets. Typically 0.1x / 1.25x
// input.
//
// The catalog is the single default-pricing source: the agentnetwork
// pricing package folds these models into per-surface tables that the
// synthesizer ships to the proxy's cost_meter.
type Model struct {
ID string
Label string
InputPer1k float64
OutputPer1k float64
CachedInputPer1k float64
CacheReadPer1k float64
CacheCreationPer1k float64
ContextWindow int
ID string
Label string
InputPer1k float64
OutputPer1k float64
ContextWindow int
}
// ProviderKind groups catalog entries for UI presentation. The split
@@ -81,17 +65,6 @@ type Provider struct {
// surface — the proxy middleware then falls back to URL sniffing
// or skips request-side enrichment.
ParserID string
// PricingSurfaces names the cost-meter pricing surfaces this
// provider's Models are priced under ("openai", "anthropic",
// "bedrock" — the llm.Parser surface the request parser stamps as
// llm.provider at billing time). NOT derivable from ParserID:
// bedrock_api and vertex_ai_api leave ParserID empty (URL-sniffed)
// yet price under "bedrock" / "anthropic", and kimi_api serves two
// body shapes so it prices under both. Nil for gateway/custom
// entries, which declare no models. Same (surface, model) pair
// contributed by two providers must carry identical rates — the
// pricing package's tests enforce that.
PricingSurfaces []string
// IdentityInjection, when non-nil, instructs the proxy to stamp
// the caller's NetBird identity onto upstream requests under the
// configured header names. Used for gateways like LiteLLM that
@@ -246,7 +219,6 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#10A37F",
ParserID: "openai",
PricingSurfaces: []string{"openai"},
// Pricing + context windows cross-checked against LiteLLM's
// model_prices_and_context_window.json. Notable corrections from
// earlier values: o4-mini repriced from $4/$16 to $1.10/$4.40
@@ -254,20 +226,20 @@ var providers = []Provider{
// family context windows split between 1.05M for full-size
// models and 272K for mini/nano/codex variants.
Models: []Model{
{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, CachedInputPer1k: 0.003, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 272000},
{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, CachedInputPer1k: 0.000175, ContextWindow: 128000},
{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, CachedInputPer1k: 0.000025, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
{ID: "gpt-5.5", Label: "GPT-5.5", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000},
{ID: "gpt-5.5-pro", Label: "GPT-5.5 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000},
{ID: "gpt-5.4-pro", Label: "GPT-5.4 Pro", InputPer1k: 0.030, OutputPer1k: 0.180, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000},
{ID: "gpt-5.3-codex", Label: "GPT-5.3 Codex", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 272000},
{ID: "gpt-5.3-chat-latest", Label: "GPT-5.3 Chat", InputPer1k: 0.00175, OutputPer1k: 0.014, ContextWindow: 128000},
{ID: "o4-mini", Label: "o4-mini", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576},
{ID: "gpt-4.1-nano", Label: "GPT-4.1 nano", InputPer1k: 0.0001, OutputPer1k: 0.0004, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000},
{ID: "gpt-4-turbo", Label: "GPT-4 Turbo", InputPer1k: 0.01, OutputPer1k: 0.03, ContextWindow: 128000},
{ID: "gpt-3.5-turbo", Label: "GPT-3.5 Turbo", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
{ID: "text-embedding-3-large", Label: "text-embedding-3-large", InputPer1k: 0.00013, OutputPer1k: 0, ContextWindow: 8191},
@@ -285,7 +257,6 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#D97757",
ParserID: "anthropic",
PricingSurfaces: []string{"anthropic"},
// Per Anthropic's current model lineup. Pricing in USD per 1k
// tokens. Context windows: 4.6+ family is 1M; Haiku 4.5 stays at
// 200K. claude-3-7-sonnet and claude-3-5-haiku retired
@@ -296,14 +267,14 @@ var providers = []Provider{
// account to be on >= 30-day data retention or all requests
// 400.
Models: []Model{
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
{ID: "claude-fable-5", Label: "Claude Fable 5", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (deprecated, retires 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
},
},
{
@@ -317,19 +288,18 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#0078D4",
ParserID: "openai",
PricingSurfaces: []string{"openai"},
// Mirrors openai_api pricing — Azure resells OpenAI models at the
// same per-token rates, just under different deployment names.
Models: []Model{
{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, CachedInputPer1k: 0.0005, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, CachedInputPer1k: 0.00025, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, CachedInputPer1k: 0.000075, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, CachedInputPer1k: 0.00002, ContextWindow: 272000},
{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, CachedInputPer1k: 0.000275, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, CachedInputPer1k: 0.0005, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, CachedInputPer1k: 0.0001, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, CachedInputPer1k: 0.00125, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, CachedInputPer1k: 0.000075, ContextWindow: 128000},
{ID: "gpt-5.5", Label: "GPT-5.5 (Azure)", InputPer1k: 0.005, OutputPer1k: 0.030, ContextWindow: 1050000},
{ID: "gpt-5.4", Label: "GPT-5.4 (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.015, ContextWindow: 1050000},
{ID: "gpt-5.4-mini", Label: "GPT-5.4 Mini (Azure)", InputPer1k: 0.00075, OutputPer1k: 0.0045, ContextWindow: 272000},
{ID: "gpt-5.4-nano", Label: "GPT-5.4 Nano (Azure)", InputPer1k: 0.0002, OutputPer1k: 0.00125, ContextWindow: 272000},
{ID: "o4-mini", Label: "o4-mini (Azure)", InputPer1k: 0.0011, OutputPer1k: 0.0044, ContextWindow: 200000},
{ID: "gpt-4.1", Label: "GPT-4.1 (Azure)", InputPer1k: 0.002, OutputPer1k: 0.008, ContextWindow: 1047576},
{ID: "gpt-4.1-mini", Label: "GPT-4.1 mini (Azure)", InputPer1k: 0.0004, OutputPer1k: 0.0016, ContextWindow: 1047576},
{ID: "gpt-4o", Label: "GPT-4o (Azure)", InputPer1k: 0.0025, OutputPer1k: 0.010, ContextWindow: 128000},
{ID: "gpt-4o-mini", Label: "GPT-4o mini (Azure)", InputPer1k: 0.00015, OutputPer1k: 0.0006, ContextWindow: 128000},
{ID: "gpt-35-turbo", Label: "GPT-3.5 Turbo (Azure)", InputPer1k: 0.0005, OutputPer1k: 0.0015, ContextWindow: 16385},
},
},
@@ -343,9 +313,6 @@ var providers = []Provider{
AuthHeaderTemplate: "Bearer ${API_KEY}",
DefaultContentType: "application/json",
BrandColor: "#FF9900",
// ParserID stays empty (path-style dispatch via IsBedrockPathStyle);
// the request parser meters these under the "bedrock" surface.
PricingSurfaces: []string{"bedrock"},
// Anthropic models on Bedrock take the anthropic.* prefix and
// follow the same lineup / pricing as the first-party Anthropic
// catalog entry above. claude-3-7-sonnet and claude-3-5-haiku
@@ -355,13 +322,13 @@ var providers = []Provider{
// Llama 3.3 70B entry kept unchanged — LiteLLM tracks only
// per-region Llama 3 entries; standalone 3.3 not yet listed.
Models: []Model{
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
{ID: "anthropic.claude-opus-4-8", Label: "Claude Opus 4.8 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-7", Label: "Claude Opus 4.7 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-6", Label: "Claude Opus 4.6 (Bedrock)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "anthropic.claude-opus-4-1", Label: "Claude Opus 4.1 (Bedrock, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
{ID: "anthropic.claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
{ID: "anthropic.claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Bedrock)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
{ID: "anthropic.claude-haiku-4-5", Label: "Claude Haiku 4.5 (Bedrock)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
{ID: "meta.llama3-3-70b-instruct", Label: "Llama 3.3 70B (Bedrock)", InputPer1k: 0.00072, OutputPer1k: 0.00072, ContextWindow: 128000},
{ID: "amazon.nova-2-lite", Label: "Amazon Nova 2 Lite (Bedrock, preview)", InputPer1k: 0.0003, OutputPer1k: 0.0025, ContextWindow: 1000000},
{ID: "amazon.nova-pro", Label: "Amazon Nova Pro (Bedrock)", InputPer1k: 0.0008, OutputPer1k: 0.0032, ContextWindow: 300000},
@@ -391,10 +358,6 @@ var providers = []Provider{
AuthHeaderTemplate: "Bearer ${API_KEY}",
DefaultContentType: "application/json",
BrandColor: "#4285F4",
// ParserID stays empty (path-style dispatch via IsVertexPathStyle);
// Anthropic-on-Vertex requests are metered under the "anthropic"
// surface with the bare, unversioned model id.
PricingSurfaces: []string{"anthropic"},
// Vertex carries the model in the URL path and authenticates with a
// service-account-minted OAuth token (api_key = "keyfile::<base64 SA>").
// Only Anthropic-on-Vertex is metered today: the request parser maps the
@@ -406,14 +369,14 @@ var providers = []Provider{
// exists — the router denies unmeterable publishers rather than forward
// them uncounted.
Models: []Model{
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, CacheReadPer1k: 0.001, CacheCreationPer1k: 0.0125, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, CacheReadPer1k: 0.0015, CacheCreationPer1k: 0.01875, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003, CacheCreationPer1k: 0.00375, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, CacheReadPer1k: 0.0001, CacheCreationPer1k: 0.00125, ContextWindow: 200000},
{ID: "claude-fable-5", Label: "Claude Fable 5 (Vertex)", InputPer1k: 0.010, OutputPer1k: 0.050, ContextWindow: 1000000},
{ID: "claude-opus-4-8", Label: "Claude Opus 4.8 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-7", Label: "Claude Opus 4.7 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-6", Label: "Claude Opus 4.6 (Vertex)", InputPer1k: 0.005, OutputPer1k: 0.025, ContextWindow: 1000000},
{ID: "claude-opus-4-1", Label: "Claude Opus 4.1 (Vertex, deprecated 2026-08-05)", InputPer1k: 0.015, OutputPer1k: 0.075, ContextWindow: 200000},
{ID: "claude-sonnet-4-6", Label: "Claude Sonnet 4.6 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
{ID: "claude-sonnet-4-5", Label: "Claude Sonnet 4.5 (Vertex)", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 200000},
{ID: "claude-haiku-4-5", Label: "Claude Haiku 4.5 (Vertex)", InputPer1k: 0.001, OutputPer1k: 0.005, ContextWindow: 200000},
},
},
{
@@ -427,7 +390,6 @@ var providers = []Provider{
DefaultContentType: "application/json",
BrandColor: "#FF7000",
ParserID: "openai",
PricingSurfaces: []string{"openai"},
// Pricing + context windows cross-checked against LiteLLM. Key
// gotchas the marketing page hides:
// - `mistral-medium-latest` aliases to Medium 3.1 ($0.40/$2),
@@ -486,10 +448,6 @@ var providers = []Provider{
// model id "k3") is account-bound seat licensing rather than a
// meterable platform key, so it's deliberately not the default.
ParserID: "",
// Both body shapes are metered: /v1/chat/completions under
// "openai", /anthropic/v1/messages under "anthropic" — so the
// K3 entry is priced on both surfaces.
PricingSurfaces: []string{"openai", "anthropic"},
// Pricing per Moonshot's platform rates at K3 launch (July 2026):
// $3/$15 per MTok with $0.30 cached input, flat across the 1M-token
// window. kimi-k3 is the ONLY model the platform serves newer
@@ -500,14 +458,7 @@ var providers = []Provider{
// The consumer app's "K3 Swarm Max" mode is not an API SKU, so it
// doesn't appear here.
Models: []Model{
// Carries both cache shapes: Moonshot reports cache hits
// OpenAI-style on /v1/chat/completions (CachedInputPer1k)
// and Anthropic-style on /anthropic/v1/messages
// (CacheReadPer1k) — $0.30/MTok either way. Each surface's
// cost formula reads only its own field, so the superset
// entry prices both endpoints correctly. No cache-creation
// rate published; writes bill at the input rate.
{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, CachedInputPer1k: 0.0003, CacheReadPer1k: 0.0003, ContextWindow: 1000000},
{ID: "kimi-k3", Label: "Kimi K3", InputPer1k: 0.003, OutputPer1k: 0.015, ContextWindow: 1000000},
},
},
{
@@ -807,28 +758,13 @@ func IsBedrockPathStyle(providerID string) bool {
func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
models := make([]api.AgentNetworkCatalogModel, 0, len(p.Models))
for _, m := range p.Models {
am := api.AgentNetworkCatalogModel{
models = append(models, api.AgentNetworkCatalogModel{
Id: m.ID,
Label: m.Label,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
ContextWindow: m.ContextWindow,
}
// Cache rates are emitted only when configured so the dashboard
// can prefill them; 0 stays off the wire (absent = no rate).
if m.CachedInputPer1k > 0 {
v := m.CachedInputPer1k
am.CachedInputPer1k = &v
}
if m.CacheReadPer1k > 0 {
v := m.CacheReadPer1k
am.CacheReadPer1k = &v
}
if m.CacheCreationPer1k > 0 {
v := m.CacheCreationPer1k
am.CacheCreationPer1k = &v
}
models = append(models, am)
})
}
kind := api.AgentNetworkCatalogProviderKindProvider
switch p.Kind {
@@ -848,10 +784,6 @@ func (p Provider) ToAPIResponse() api.AgentNetworkCatalogProvider {
BrandColor: p.BrandColor,
Models: models,
}
if len(p.PricingSurfaces) > 0 {
surfaces := append([]string(nil), p.PricingSurfaces...)
resp.PricingSurfaces = &surfaces
}
if len(p.ExtraHeaders) > 0 {
extras := make([]api.AgentNetworkCatalogExtraHeader, 0, len(p.ExtraHeaders))
for _, h := range p.ExtraHeaders {

View File

@@ -7,7 +7,6 @@ package handlers
import (
"encoding/json"
"math"
"net/http"
"net/url"
"strings"
@@ -16,7 +15,6 @@ import (
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
nbcontext "github.com/netbirdio/netbird/management/server/context"
"github.com/netbirdio/netbird/shared/management/http/api"
@@ -54,45 +52,11 @@ func (h *handler) getCatalogProviders(w http.ResponseWriter, r *http.Request) {
entries := catalog.All()
out := make([]api.AgentNetworkCatalogProvider, 0, len(entries))
for _, e := range entries {
resp := e.ToAPIResponse()
applyDefaultPricing(e, &resp)
out = append(out, resp)
out = append(out, e.ToAPIResponse())
}
util.WriteJSONObject(r.Context(), w, out)
}
// applyDefaultPricing overwrites the catalog response's model rates with
// the LIVE default pricing table, which may differ from the compiled-in
// catalog rates when the operator provides a defaults_llm_pricing.yaml.
// This keeps the dashboard's model-row prefill identical to what the
// proxy will actually bill — the same table the synthesizer ships.
func applyDefaultPricing(cp catalog.Provider, resp *api.AgentNetworkCatalogProvider) {
if len(cp.PricingSurfaces) == 0 {
return
}
for i := range resp.Models {
m := &resp.Models[i]
e, ok := pricing.LookupDefault(cp.PricingSurfaces, m.Id)
if !ok {
continue
}
m.InputPer1k = e.InputPer1k
m.OutputPer1k = e.OutputPer1k
m.CachedInputPer1k = positiveRatePtr(e.CachedInputPer1k)
m.CacheReadPer1k = positiveRatePtr(e.CacheReadPer1k)
m.CacheCreationPer1k = positiveRatePtr(e.CacheCreationPer1k)
}
}
// positiveRatePtr renders a cache rate for the API: absent (nil) when
// unset, matching the catalog response convention.
func positiveRatePtr(v float64) *float64 {
if v <= 0 {
return nil
}
return &v
}
func (h *handler) getAllProviders(w http.ResponseWriter, r *http.Request) {
userAuth, err := nbcontext.GetUserAuthFromContext(r.Context())
if err != nil {
@@ -249,38 +213,5 @@ func validate(req *api.AgentNetworkProviderRequest, requireAPIKey bool) error {
if requireAPIKey && (req.ApiKey == nil || strings.TrimSpace(*req.ApiKey) == "") {
return status.Errorf(status.InvalidArgument, "api_key is required")
}
if req.Models != nil {
for i, m := range *req.Models {
if err := validateModel(i, m); err != nil {
return err
}
}
}
return nil
}
// validateModel is the single ingress guard for operator-entered pricing:
// these rates are synthesized into the proxy's cost_meter config verbatim,
// and a negative or non-finite rate there would poison every cost the
// proxy records, so reject at the API boundary.
func validateModel(i int, m api.AgentNetworkProviderModel) error {
if strings.TrimSpace(m.Id) == "" {
return status.Errorf(status.InvalidArgument, "models[%d]: id is required", i)
}
rates := map[string]*float64{
"input_per_1k": &m.InputPer1k,
"output_per_1k": &m.OutputPer1k,
"cached_input_per_1k": m.CachedInputPer1k,
"cache_read_per_1k": m.CacheReadPer1k,
"cache_creation_per_1k": m.CacheCreationPer1k,
}
for field, v := range rates {
if v == nil {
continue
}
if *v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0) {
return status.Errorf(status.InvalidArgument, "models[%d] (%s): %s must be a finite, non-negative USD rate", i, m.Id, field)
}
}
return nil
}

View File

@@ -1,53 +0,0 @@
package handlers
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
func f(v float64) *float64 { return &v }
// TestValidate_ModelRates guards the single ingress point for operator-entered
// pricing. These rates flow verbatim into the proxy's cost_meter config at
// synthesis time; the proxy treats a bad rate as a chain-build failure, so
// rejecting here is what keeps an account's gateway from going down.
func TestValidate_ModelRates(t *testing.T) {
base := func(models ...api.AgentNetworkProviderModel) *api.AgentNetworkProviderRequest {
key := "sk-test"
return &api.AgentNetworkProviderRequest{
ProviderId: "openai_api",
Name: "OpenAI",
UpstreamUrl: "https://api.openai.com",
ApiKey: &key,
Models: &models,
}
}
valid := api.AgentNetworkProviderModel{
Id: "gpt-4o", InputPer1k: 0.0025, OutputPer1k: 0.01,
CachedInputPer1k: f(0.00125),
}
require.NoError(t, validate(base(valid), true), "finite non-negative rates must pass")
zeroRates := api.AgentNetworkProviderModel{Id: "self-hosted-llama", InputPer1k: 0, OutputPer1k: 0}
require.NoError(t, validate(base(zeroRates), true), "explicit zero prices are allowed (free / self-hosted models)")
cases := map[string]api.AgentNetworkProviderModel{
"empty id": {Id: " ", InputPer1k: 0.001, OutputPer1k: 0.002},
"negative input": {Id: "m", InputPer1k: -0.001, OutputPer1k: 0.002},
"negative output": {Id: "m", InputPer1k: 0.001, OutputPer1k: -0.002},
"NaN input": {Id: "m", InputPer1k: math.NaN(), OutputPer1k: 0.002},
"Inf output": {Id: "m", InputPer1k: 0.001, OutputPer1k: math.Inf(1)},
"negative cached": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CachedInputPer1k: f(-1)},
"NaN cache read": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheReadPer1k: f(math.NaN())},
"Inf cache creation": {Id: "m", InputPer1k: 0.001, OutputPer1k: 0.002, CacheCreationPer1k: f(math.Inf(-1))},
}
for name, m := range cases {
assert.Error(t, validate(base(m), true), "case %q must be rejected", name)
}
}

View File

@@ -1,156 +0,0 @@
// Package pricing builds the default LLM pricing table the synthesizer
// ships to the proxy's cost_meter middleware. The catalog is the single
// source of default rates: every catalog provider's models are folded
// into the pricing surfaces the provider declares (PricingSurfaces),
// then a small supplemental list adds priced-but-not-operator-selectable
// entries. Management is the sole pricing authority — the proxy carries
// no embedded price list and bills exclusively from the table it is
// sent.
//go:generate go run gen.go
package pricing
import (
"sync"
"sync/atomic"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
)
// Entry is a single model's pricing in USD per 1k tokens. This struct IS
// the wire shape: the synthesizer marshals it verbatim into cost_meter's
// ConfigJSON, and the proxy unmarshals the same field names.
//
// A zero rate means "no rate configured" — the proxy bills that cache
// bucket at InputPer1k (identical semantics to the retired proxy-embedded
// table). CachedInputPer1k is the OpenAI shape (cached prompt tokens are
// a subset of input); CacheReadPer1k / CacheCreationPer1k are the
// Anthropic shape (additive buckets).
type Entry struct {
InputPer1k float64 `json:"input_per_1k"`
OutputPer1k float64 `json:"output_per_1k"`
CachedInputPer1k float64 `json:"cached_input_per_1k,omitempty"`
CacheReadPer1k float64 `json:"cache_read_per_1k,omitempty"`
CacheCreationPer1k float64 `json:"cache_creation_per_1k,omitempty"`
}
// supplementalDefaults are (surface, model) entries that are priced but
// deliberately not operator-selectable in the catalog. Each carries a
// reason; when one of these models joins a catalog lineup, delete the
// row here — the collision test fails loudly if the rates ever disagree.
var supplementalDefaults = map[string]map[string]Entry{
"openai": {
// GPT-5 (2025) family — kept for gateway requests using the
// unsuffixed ids; the dashboard offers only the 5.x lineup.
"gpt-5": {InputPer1k: 0.00125, OutputPer1k: 0.01, CachedInputPer1k: 0.000125},
"gpt-5-mini": {InputPer1k: 0.00025, OutputPer1k: 0.002, CachedInputPer1k: 0.000025},
"gpt-5-nano": {InputPer1k: 0.00005, OutputPer1k: 0.0004, CachedInputPer1k: 0.000005},
},
"anthropic": {
// claude-opus-5 is not yet in the catalog lineup but gateway /
// grandfathered traffic uses it; priced so it isn't skipped.
"claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
// "kimi-k3[1m]" is the 1M-context alias some Claude Code guides
// configure against Moonshot's Anthropic-compatible endpoint;
// priced identically to kimi-k3 so those requests aren't skipped.
"kimi-k3[1m]": {InputPer1k: 0.003, OutputPer1k: 0.015, CacheReadPer1k: 0.0003},
},
"bedrock": {
"anthropic.claude-opus-5": {InputPer1k: 0.005, OutputPer1k: 0.025, CacheReadPer1k: 0.0005, CacheCreationPer1k: 0.00625},
},
}
var (
compiledOnce sync.Once
compiledTable map[string]map[string]Entry
// mergedTable holds the current live table when a pricing defaults
// file is loaded: the file merged entry-whole over the compiled-in
// base. Nil while no file is loaded (or after the file is removed),
// in which case the compiled-in table serves. Swapped atomically by
// the file loader/reloader; readers never block.
mergedTable atomic.Pointer[map[string]map[string]Entry]
)
// DefaultTable returns the current default pricing table keyed
// surface -> model -> Entry: the management-side defaults file (see
// LoadFile / StartReloader) when one is loaded, merged over the
// compiled-in catalog table, which alone serves as the fallback when no
// file exists. The snapshot may change between calls as the file is
// re-read — consumers (the synthesizer on every reconcile, the catalog
// endpoint on every request) pick up fresh rates automatically. Callers
// must not mutate the returned maps.
func DefaultTable() map[string]map[string]Entry {
if t := mergedTable.Load(); t != nil {
return *t
}
return compiledBase()
}
// compiledBase returns the compiled-in table (catalog + supplementals),
// built once.
func compiledBase() map[string]map[string]Entry {
compiledOnce.Do(func() {
compiledTable = buildDefaultTable()
})
return compiledTable
}
func buildDefaultTable() map[string]map[string]Entry {
out := make(map[string]map[string]Entry)
for _, p := range catalog.All() {
for _, surface := range p.PricingSurfaces {
inner, ok := out[surface]
if !ok {
inner = make(map[string]Entry)
out[surface] = inner
}
for _, m := range p.Models {
// First writer wins; providers contributing the same
// (surface, model) must agree on rates — enforced by
// TestDefaultTable_NoConflictingContributions.
if _, dup := inner[m.ID]; dup {
continue
}
inner[m.ID] = entryFromCatalogModel(m)
}
}
}
for surface, models := range supplementalDefaults {
inner, ok := out[surface]
if !ok {
inner = make(map[string]Entry)
out[surface] = inner
}
for id, e := range models {
if _, dup := inner[id]; dup {
continue
}
inner[id] = e
}
}
return out
}
func entryFromCatalogModel(m catalog.Model) Entry {
return Entry{
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
CachedInputPer1k: m.CachedInputPer1k,
CacheReadPer1k: m.CacheReadPer1k,
CacheCreationPer1k: m.CacheCreationPer1k,
}
}
// LookupDefault returns the default entry for model on the first of the
// given surfaces that prices it. Used by the synthesizer to seed a
// per-provider entry with default cache rates before overlaying the
// operator's stored prices.
func LookupDefault(surfaces []string, model string) (Entry, bool) {
table := DefaultTable()
for _, s := range surfaces {
if e, ok := table[s][model]; ok {
return e, true
}
}
return Entry{}, false
}

View File

@@ -1,148 +0,0 @@
package pricing
import (
"math"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
)
// TestDefaultTable_CoversEveryCatalogModel replaces the proxy's old
// hand-maintained coverage list: because the table is built FROM the
// catalog, drift is impossible by construction — this test guards the
// fold itself (every catalog model of every surfaced provider resolves,
// with exactly the catalog's rates).
func TestDefaultTable_CoversEveryCatalogModel(t *testing.T) {
table := DefaultTable()
for _, p := range catalog.All() {
if len(p.PricingSurfaces) == 0 {
assert.Empty(t, p.Models, "catalog entry %s declares models but no pricing surfaces — those models would never be priced", p.ID)
continue
}
for _, surface := range p.PricingSurfaces {
byModel, ok := table[surface]
require.True(t, ok, "surface %q (provider %s) missing from default table", surface, p.ID)
for _, m := range p.Models {
e, ok := byModel[m.ID]
require.True(t, ok, "%s/%s (provider %s) missing from default table", surface, m.ID, p.ID)
assert.Equal(t, m.InputPer1k, e.InputPer1k, "%s/%s input rate", surface, m.ID)
assert.Equal(t, m.OutputPer1k, e.OutputPer1k, "%s/%s output rate", surface, m.ID)
}
}
}
}
// TestDefaultTable_NoConflictingContributions enforces the collision rule
// documented on catalog.Provider.PricingSurfaces: when two catalog
// providers contribute the same (surface, model) pair — azure/vertex
// mirroring openai/anthropic, kimi on both surfaces — their rates must be
// identical, because the surface-keyed table can only hold one entry.
// If a provider ever diverges (e.g. Azure reprices a model), this fails
// and the divergence must move to per-provider-record pricing.
func TestDefaultTable_NoConflictingContributions(t *testing.T) {
type contribution struct {
providerID string
entry Entry
}
seen := map[string]map[string]contribution{}
for _, p := range catalog.All() {
for _, surface := range p.PricingSurfaces {
if seen[surface] == nil {
seen[surface] = map[string]contribution{}
}
for _, m := range p.Models {
e := entryFromCatalogModel(m)
if prev, dup := seen[surface][m.ID]; dup {
assert.Equal(t, prev.entry, e,
"%s/%s: %s and %s contribute different rates", surface, m.ID, prev.providerID, p.ID)
continue
}
seen[surface][m.ID] = contribution{providerID: p.ID, entry: e}
}
}
}
// Supplemental entries must never shadow a catalog-contributed model —
// they exist precisely because the catalog does NOT list them.
for surface, models := range supplementalDefaults {
for id := range models {
_, fromCatalog := seen[surface][id]
assert.False(t, fromCatalog, "supplemental %s/%s is now in the catalog — delete the supplemental row", surface, id)
}
}
}
// TestDefaultTable_AllRatesFiniteNonNegative mirrors the proxy-side
// NewTable validation so a bad catalog edit is caught here, at unit-test
// time, rather than as a chain-build failure on every proxy.
func TestDefaultTable_AllRatesFiniteNonNegative(t *testing.T) {
for surface, models := range DefaultTable() {
for id, e := range models {
for field, v := range map[string]float64{
"input": e.InputPer1k,
"output": e.OutputPer1k,
"cached_input": e.CachedInputPer1k,
"cache_read": e.CacheReadPer1k,
"cache_creation": e.CacheCreationPer1k,
} {
assert.False(t, v < 0 || math.IsNaN(v) || math.IsInf(v, 0),
"%s/%s: %s rate %v must be finite and non-negative", surface, id, field, v)
}
}
}
}
// TestDefaultTable_PinnedRates pins rates that previously drifted or are
// easy to mis-enter (carried over from the proxy's retired
// defaults_coverage_test), plus the supplemental entries.
func TestDefaultTable_PinnedRates(t *testing.T) {
table := DefaultTable()
gpt54 := table["openai"]["gpt-5.4"]
assert.InDelta(t, 0.0025, gpt54.InputPer1k, 1e-9, "gpt-5.4 input")
assert.InDelta(t, 0.015, gpt54.OutputPer1k, 1e-9, "gpt-5.4 output")
assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "gpt-5.4 cached input")
sonnet := table["bedrock"]["anthropic.claude-sonnet-4-5"]
assert.InDelta(t, 0.003, sonnet.InputPer1k, 1e-9, "bedrock sonnet-4-5 input")
assert.InDelta(t, 0.015, sonnet.OutputPer1k, 1e-9, "bedrock sonnet-4-5 output")
assert.InDelta(t, 0.0003, sonnet.CacheReadPer1k, 1e-9, "bedrock sonnet-4-5 cache read")
assert.InDelta(t, 0.00375, sonnet.CacheCreationPer1k, 1e-9, "bedrock sonnet-4-5 cache creation")
// Vertex Claude prices under "anthropic" with the bare id.
fable := table["anthropic"]["claude-fable-5"]
assert.InDelta(t, 0.010, fable.InputPer1k, 1e-9, "claude-fable-5 input")
assert.InDelta(t, 0.0125, fable.CacheCreationPer1k, 1e-9, "claude-fable-5 cache creation")
// Supplementals present on their surfaces.
for surface, ids := range map[string][]string{
"openai": {"gpt-5", "gpt-5-mini", "gpt-5-nano"},
"anthropic": {"claude-opus-5", "kimi-k3[1m]", "kimi-k3"},
"bedrock": {"anthropic.claude-opus-5"},
} {
for _, id := range ids {
_, ok := table[surface][id]
assert.True(t, ok, "%s/%s must be priced", surface, id)
}
}
// Embeddings bill input-only — output stays zero.
emb := table["openai"]["text-embedding-3-large"]
assert.Zero(t, emb.OutputPer1k, "embedding output rate must be zero")
assert.Positive(t, emb.InputPer1k, "embedding input rate must be set")
}
func TestLookupDefault_SurfaceOrder(t *testing.T) {
// kimi-k3 exists on both surfaces; first surface in the slice wins.
e, ok := LookupDefault([]string{"openai", "anthropic"}, "kimi-k3")
require.True(t, ok)
assert.InDelta(t, 0.003, e.InputPer1k, 1e-9)
_, ok = LookupDefault([]string{"bedrock"}, "gpt-4o")
assert.False(t, ok, "gpt-4o is not a bedrock model")
_, ok = LookupDefault(nil, "gpt-4o")
assert.False(t, ok, "no surfaces, no match")
}

View File

@@ -1,109 +0,0 @@
package pricing
import (
"bytes"
"fmt"
"sort"
"strconv"
)
// MarshalDefaultsYAML renders the built-in default pricing table (catalog
// + supplementals, WITHOUT any operator override) as the YAML schema
// LoadOverrideFile consumes. It backs the generated
// defaults_llm_pricing.example.yaml so operators start from a file that
// matches the compiled-in rates exactly; a golden test keeps the two in
// sync. Output is deterministic (sorted surfaces and models).
func MarshalDefaultsYAML() []byte {
var b bytes.Buffer
b.WriteString(exampleHeader)
table := buildDefaultTable()
surfaces := make([]string, 0, len(table))
for s := range table {
surfaces = append(surfaces, s)
}
sort.Strings(surfaces)
for _, surface := range surfaces {
fmt.Fprintf(&b, "\n%s:\n", surface)
models := table[surface]
ids := make([]string, 0, len(models))
for id := range models {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
e := models[id]
fmt.Fprintf(&b, " %s:\n", yamlKey(id))
fmt.Fprintf(&b, " input_per_1k: %s\n", rate(e.InputPer1k))
fmt.Fprintf(&b, " output_per_1k: %s\n", rate(e.OutputPer1k))
if e.CachedInputPer1k > 0 {
fmt.Fprintf(&b, " cached_input_per_1k: %s\n", rate(e.CachedInputPer1k))
}
if e.CacheReadPer1k > 0 {
fmt.Fprintf(&b, " cache_read_per_1k: %s\n", rate(e.CacheReadPer1k))
}
if e.CacheCreationPer1k > 0 {
fmt.Fprintf(&b, " cache_creation_per_1k: %s\n", rate(e.CacheCreationPer1k))
}
}
}
return b.Bytes()
}
// rate renders a USD-per-1k rate without float noise ("0.00015", not
// "0.000150000000...").
func rate(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64)
}
// yamlKey quotes model ids that YAML would otherwise misparse (e.g.
// "kimi-k3[1m]" starts a flow sequence unquoted).
func yamlKey(id string) string {
for _, r := range id {
switch r {
case '[', ']', '{', '}', ':', '#', ',', '&', '*', '!', '|', '>', '\'', '"', '%', '@', '`':
return strconv.Quote(id)
}
}
return id
}
const exampleHeader = `# Default LLM pricing used by NetBird's Agent Network cost metering.
# GENERATED from the management catalog — do not edit this file in the
# repository; regenerate with:
#
# go generate ./management/internals/modules/agentnetwork/pricing
#
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
# any path configured via management.json:
#
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
#
# ) and adjust the entries you want to change. Management re-reads the
# file periodically (mtime poll, every minute): the live table feeds the
# proxies' cost metering and the dashboard's model-price prefill, so
# edits apply without a restart. Your file only needs the entries you
# want to change — but each entry REPLACES the built-in entry for that
# surface+model whole, so repeat the cache rates you want to keep.
# Unknown fields and negative or non-finite rates are rejected: at
# startup that fails boot (for an explicitly configured path); at
# runtime the previous table is kept and a warning is logged. Deleting
# the file reverts to the built-in defaults below.
#
# Top-level keys are pricing surfaces — the parser shape requests are
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
# the normalized id the proxy meters (version/region suffixes stripped).
#
# Values are USD per 1_000 tokens. Optional cache fields:
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
# (a SUBSET of input tokens). Absent -> cached
# portion bills at input_per_1k.
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
# (ADDITIVE to input). Absent -> input rate.
# cache_creation_per_1k Anthropic shape: rate for cache_creation
# tokens (ADDITIVE to input). Absent -> input
# rate.
`

View File

@@ -1,20 +0,0 @@
//go:build ignore
// Regenerates defaults_llm_pricing.example.yaml from the compiled-in
// default pricing table. Run via:
//
// go generate ./management/internals/modules/agentnetwork/pricing
package main
import (
"log"
"os"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
)
func main() {
if err := os.WriteFile("defaults_llm_pricing.example.yaml", pricing.MarshalDefaultsYAML(), 0o644); err != nil {
log.Fatalf("write defaults_llm_pricing.example.yaml: %v", err)
}
}

View File

@@ -1,244 +0,0 @@
package pricing
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"math"
"os"
"sync"
"time"
log "github.com/sirupsen/logrus"
"gopkg.in/yaml.v3"
)
// DefaultFileName is the basename probed under management's datadir when
// AgentNetwork.PricingDefaultsFile doesn't configure an explicit path.
const DefaultFileName = "defaults_llm_pricing.yaml"
// ReloadInterval is the cadence at which the pricing file's mtime is
// polled for changes.
const ReloadInterval = time.Minute
// maxFileBytes bounds the pricing file read so a misconfigured path
// (pointed at a huge file) cannot exhaust process memory.
const maxFileBytes = 4 << 20
// pricingFile mirrors the on-disk YAML schema — the same schema the
// proxy's retired embedded defaults_pricing.yaml used, so files written
// for it keep working. Keys are pricing surfaces ("openai", "anthropic",
// "bedrock"); nested keys are normalized model ids.
type pricingFile map[string]map[string]struct {
InputPer1k float64 `yaml:"input_per_1k"`
OutputPer1k float64 `yaml:"output_per_1k"`
CachedInputPer1k float64 `yaml:"cached_input_per_1k"`
CacheReadPer1k float64 `yaml:"cache_read_per_1k"`
CacheCreationPer1k float64 `yaml:"cache_creation_per_1k"`
}
// fileState tracks the watched pricing file across reloads.
var fileState struct {
mu sync.Mutex
path string
mtime int64
}
// LoadFile loads the management-side pricing defaults file and makes it
// the live table (merged entry-whole over the compiled-in fallback; see
// DefaultTable). The path stays registered for the periodic reloader, so
// later edits — or the file (re)appearing after deletion — are picked up
// without a restart.
//
// required governs the missing-file case: true for an explicitly
// configured path (a typo must fail startup rather than silently bill
// with built-ins the operator believes they replaced), false for the
// conventional <datadir>/defaults_llm_pricing.yaml probe (absent file =
// compiled-in defaults, still watched in case it appears). A file that
// exists but is malformed is always an error at load time.
func LoadFile(path string, required bool) error {
if path == "" {
return nil
}
fileState.mu.Lock()
fileState.path = path
fileState.mu.Unlock()
table, mtime, err := readFile(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) && !required {
return nil
}
return err
}
storeFileTable(table, mtime)
return nil
}
// StartReloader launches the periodic mtime-poll goroutine for the file
// registered by LoadFile. Runtime failures are lenient — a parse error
// keeps the previously loaded table and logs a warning; a deleted file
// reverts to the compiled-in defaults — so a mid-edit save can never
// take pricing down. Returns immediately when no path was registered.
func StartReloader(ctx context.Context, interval time.Duration) {
fileState.mu.Lock()
path := fileState.path
fileState.mu.Unlock()
if path == "" {
return
}
if interval <= 0 {
interval = ReloadInterval
}
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
reload()
}
}
}()
}
// reload performs one mtime check + reload cycle.
func reload() {
fileState.mu.Lock()
path, lastMtime := fileState.path, fileState.mtime
fileState.mu.Unlock()
st, err := os.Stat(path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// File removed (or not yet created): serve compiled-in
// defaults and reset mtime so a future (re)appearance loads.
if mergedTable.Swap(nil) != nil {
log.Warnf("agent-network pricing defaults file %s removed; reverting to built-in defaults", path)
}
setMtime(0)
return
}
log.Warnf("agent-network pricing defaults reload: stat %s: %v", path, err)
return
}
if st.ModTime().UnixNano() == lastMtime {
return
}
table, mtime, err := readFile(path)
if err != nil {
// Keep the previously loaded table — never blank prices because
// an operator saved mid-edit.
log.Warnf("agent-network pricing defaults reload failed for %s (keeping previous table): %v", path, err)
return
}
storeFileTable(table, mtime)
log.Infof("agent-network pricing defaults reloaded from %s", path)
}
func readFile(path string) (map[string]map[string]Entry, int64, error) {
f, err := os.Open(path)
if err != nil {
return nil, 0, fmt.Errorf("open pricing defaults %s: %w", path, err)
}
defer func() { _ = f.Close() }()
st, err := f.Stat()
if err != nil {
return nil, 0, fmt.Errorf("stat pricing defaults %s: %w", path, err)
}
data, err := io.ReadAll(io.LimitReader(f, maxFileBytes+1))
if err != nil {
return nil, 0, fmt.Errorf("read pricing defaults %s: %w", path, err)
}
if len(data) > maxFileBytes {
return nil, 0, fmt.Errorf("pricing defaults %s exceeds %d bytes", path, maxFileBytes)
}
table, err := parsePricingYAML(data)
if err != nil {
return nil, 0, fmt.Errorf("parse pricing defaults %s: %w", path, err)
}
return table, st.ModTime().UnixNano(), nil
}
// storeFileTable merges the parsed file over the compiled-in base and
// publishes the result as the live table. File entries replace the
// built-in entry for the same (surface, model) whole — they are not
// field-merged — and surfaces/models the file doesn't mention keep the
// built-in rates, so a partial file only needs the entries it changes.
func storeFileTable(table map[string]map[string]Entry, mtime int64) {
base := compiledBase()
merged := make(map[string]map[string]Entry, len(base)+len(table))
for surface, models := range base {
inner := make(map[string]Entry, len(models))
for id, e := range models {
inner[id] = e
}
merged[surface] = inner
}
for surface, models := range table {
inner, ok := merged[surface]
if !ok {
inner = make(map[string]Entry, len(models))
merged[surface] = inner
}
for id, e := range models {
inner[id] = e
}
}
mergedTable.Store(&merged)
setMtime(mtime)
}
func setMtime(v int64) {
fileState.mu.Lock()
fileState.mtime = v
fileState.mu.Unlock()
}
// parsePricingYAML decodes and validates the pricing YAML. Unknown
// fields are rejected (typos surface instead of silently pricing at 0)
// and every rate must be a finite, non-negative USD amount — the same
// constraints the HTTP API enforces on operator per-provider prices.
func parsePricingYAML(data []byte) (map[string]map[string]Entry, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var raw pricingFile
if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("decode yaml: %w", err)
}
out := make(map[string]map[string]Entry, len(raw))
for surface, models := range raw {
inner := make(map[string]Entry, len(models))
for model, e := range models {
for field, v := range map[string]float64{
"input_per_1k": e.InputPer1k,
"output_per_1k": e.OutputPer1k,
"cached_input_per_1k": e.CachedInputPer1k,
"cache_read_per_1k": e.CacheReadPer1k,
"cache_creation_per_1k": e.CacheCreationPer1k,
} {
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return nil, fmt.Errorf("%s/%s: %s must be a finite, non-negative rate, got %v", surface, model, field, v)
}
}
inner[model] = Entry{
InputPer1k: e.InputPer1k,
OutputPer1k: e.OutputPer1k,
CachedInputPer1k: e.CachedInputPer1k,
CacheReadPer1k: e.CacheReadPer1k,
CacheCreationPer1k: e.CacheCreationPer1k,
}
}
out[surface] = inner
}
return out, nil
}

View File

@@ -1,173 +0,0 @@
package pricing
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// resetFileState snapshots and restores the package-level file state so
// tests stay order-independent.
func resetFileState(t *testing.T) {
t.Helper()
prevMerged := mergedTable.Load()
fileState.mu.Lock()
prevPath, prevMtime := fileState.path, fileState.mtime
fileState.mu.Unlock()
t.Cleanup(func() {
mergedTable.Store(prevMerged)
fileState.mu.Lock()
fileState.path, fileState.mtime = prevPath, prevMtime
fileState.mu.Unlock()
})
}
func writePricing(t *testing.T, path, yml string) {
t.Helper()
require.NoError(t, os.WriteFile(path, []byte(yml), 0o600))
}
func TestLoadFile_MergesOverCompiledDefaults(t *testing.T) {
resetFileState(t)
path := filepath.Join(t.TempDir(), DefaultFileName)
writePricing(t, path, `
openai:
# Reprice a built-in model. The entry replaces the built-in WHOLE:
# omitting the cache rate here drops the built-in 0.00125 discount.
gpt-4o:
input_per_1k: 0.9
output_per_1k: 1.8
# A model NetBird doesn't know at all.
my-private-ft:
input_per_1k: 0.01
output_per_1k: 0.02
cached_input_per_1k: 0.005
gemini:
gemini-pro:
input_per_1k: 0.00125
output_per_1k: 0.005
`)
require.NoError(t, LoadFile(path, true))
table := DefaultTable()
gpt4o := table["openai"]["gpt-4o"]
assert.InDelta(t, 0.9, gpt4o.InputPer1k, 1e-9, "file rate replaces the compiled-in rate")
assert.Zero(t, gpt4o.CachedInputPer1k, "entries replace whole — omitted cache rate is dropped, not inherited")
ft := table["openai"]["my-private-ft"]
assert.InDelta(t, 0.005, ft.CachedInputPer1k, 1e-9, "unknown models are added to the surface")
_, ok := table["gemini"]["gemini-pro"]
assert.True(t, ok, "a surface the catalog doesn't declare can be added")
// Untouched entries keep compiled-in rates (catalog, other surface,
// supplemental).
assert.InDelta(t, 0.00015, table["openai"]["gpt-4o-mini"].InputPer1k, 1e-9, "unlisted model keeps compiled rate")
assert.InDelta(t, 0.003, table["anthropic"]["claude-sonnet-4-5"].InputPer1k, 1e-9, "unlisted surface untouched")
assert.InDelta(t, 0.00125, table["openai"]["gpt-5"].InputPer1k, 1e-9, "supplemental entries untouched")
// The synthesizer-facing lookup reads the live table too.
e, ok := LookupDefault([]string{"openai"}, "gpt-4o")
require.True(t, ok)
assert.InDelta(t, 0.9, e.InputPer1k, 1e-9, "LookupDefault serves the file-backed rate")
}
func TestLoadFile_MissingPath(t *testing.T) {
resetFileState(t)
missing := filepath.Join(t.TempDir(), DefaultFileName)
require.Error(t, LoadFile(missing, true),
"explicitly configured path that doesn't exist must fail startup")
require.NoError(t, LoadFile(missing, false),
"conventional datadir probe tolerates an absent file (compiled-in defaults serve)")
assert.Nil(t, mergedTable.Load(), "no file, no merged table")
fileState.mu.Lock()
path := fileState.path
fileState.mu.Unlock()
assert.Equal(t, missing, path, "the path stays registered so the reloader picks the file up when it appears")
}
func TestLoadFile_RejectsInvalid(t *testing.T) {
resetFileState(t)
dir := t.TempDir()
cases := map[string]string{
"unknown field (typo)": "openai:\n gpt-4o:\n input_per1k: 0.1\n",
"negative rate": "openai:\n gpt-4o:\n input_per_1k: -0.1\n",
"non-numeric rate": "openai:\n gpt-4o:\n input_per_1k: cheap\n",
"not a mapping": "- just\n- a\n- list\n",
}
for name, yml := range cases {
path := filepath.Join(dir, DefaultFileName)
writePricing(t, path, yml)
assert.Error(t, LoadFile(path, true), "case %q must be rejected", name)
}
}
// TestReload_LifeCycle drives the reloader's single-shot reload through
// its full lifecycle: file edit picked up on mtime change, a broken save
// keeps the previous table, and file removal reverts to the compiled-in
// defaults (then a re-created file loads again).
func TestReload_LifeCycle(t *testing.T) {
resetFileState(t)
path := filepath.Join(t.TempDir(), DefaultFileName)
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.5\n output_per_1k: 1\n")
require.NoError(t, LoadFile(path, true))
require.InDelta(t, 0.5, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9)
// Edit: new mtime, new rates.
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.7\n output_per_1k: 1.4\n")
bumpMtime(t, path)
reload()
assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9, "edit must be picked up")
// Broken save: previous table survives.
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: -1\n")
bumpMtime(t, path)
reload()
assert.InDelta(t, 0.7, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
"a malformed save must keep the previously loaded table, never blank prices")
// Removal: compiled-in defaults serve again.
require.NoError(t, os.Remove(path))
reload()
assert.InDelta(t, 0.0025, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
"file removal reverts to the compiled-in rate")
// Re-created file loads without a restart.
writePricing(t, path, "openai:\n gpt-4o:\n input_per_1k: 0.9\n output_per_1k: 1.8\n")
bumpMtime(t, path)
reload()
assert.InDelta(t, 0.9, DefaultTable()["openai"]["gpt-4o"].InputPer1k, 1e-9,
"a file appearing after removal (or after a missing-probe boot) must load")
}
// bumpMtime pushes the file's mtime forward past the previously recorded
// value — timestamps can otherwise collide within the test's timescale.
func bumpMtime(t *testing.T, path string) {
t.Helper()
st, err := os.Stat(path)
require.NoError(t, err)
next := st.ModTime().Add(2 * 1e9)
require.NoError(t, os.Chtimes(path, next, next))
}
// TestExampleYAML_InSyncWithBuiltins is the golden guard for
// defaults_llm_pricing.example.yaml: the shipped example must stay
// byte-identical to what the compiled-in table renders (catalog edits
// require `go generate ./management/internals/modules/agentnetwork/pricing`)
// and must round-trip through the same parser operators' files go
// through, reproducing the compiled-in table exactly.
func TestExampleYAML_InSyncWithBuiltins(t *testing.T) {
onDisk, err := os.ReadFile("defaults_llm_pricing.example.yaml")
require.NoError(t, err, "example file must exist next to the package")
require.Equal(t, string(MarshalDefaultsYAML()), string(onDisk),
"defaults_llm_pricing.example.yaml is stale — run: go generate ./management/internals/modules/agentnetwork/pricing")
parsed, err := parsePricingYAML(onDisk)
require.NoError(t, err, "the example must be a valid pricing defaults file")
assert.Equal(t, buildDefaultTable(), parsed,
"parsing the example must reproduce the compiled-in table exactly")
}

View File

@@ -233,11 +233,6 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
return nil, err
}
costMeterJSON, err := buildCostMeterConfigJSON(enabledProviders, groupIndex)
if err != nil {
return nil, err
}
mergedGuardrails := mergeGuardrails(enabledPolicies, guardrailsByID)
applyAccountCollectionControls(&mergedGuardrails, settings)
// The proxy guardrail is a per-provider fail-closed backstop; the
@@ -253,7 +248,7 @@ func SynthesizeServices(ctx context.Context, s store.Store, accountID string) ([
// Use the merged decision (account settings OR policy-required redaction),
// not the raw account flag, so a policy that mandates PII redaction is
// honored by the capture parsers even when the account toggle is off.
middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, costMeterJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled)
middlewares := buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, mergedGuardrails.PromptCapture.RedactPii, mergedGuardrails.PromptCapture.Enabled)
priv, pub, err := pickServiceSessionKeys(enabledProviders)
if err != nil {
@@ -705,7 +700,7 @@ func buildIdentityExtraHeaders(p *types.Provider, extras []catalog.ExtraHeader)
// requests bound for gateways like LiteLLM that key budgets and
// attribution off request headers. CanMutate is required so its
// HeadersAdd / HeadersRemove pass the framework's mutation gate.
func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, costMeterJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig {
func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON []byte, redactPii, capturePromptContent bool) []rpservice.MiddlewareConfig {
// Both parsers receive an explicit capture flag derived from the account's
// enable_prompt_collection toggle; nil/unset would default to the legacy
// "always emit" behavior in the middleware, which is precisely what we
@@ -774,13 +769,10 @@ func buildMiddlewareChain(routerCfgJSON, identityInjectJSON, guardrailJSON, cost
ConfigJSON: []byte("{}"),
},
{
// Carries the full pricing table (defaults + per-provider
// operator prices) so the proxy bills without an embedded
// price list; see buildCostMeterConfigJSON.
ID: middlewareIDCostMeter,
Enabled: true,
Slot: rpservice.MiddlewareSlotOnResponse,
ConfigJSON: costMeterJSON,
ConfigJSON: []byte("{}"),
},
{
ID: middlewareIDLLMResponseParser,

View File

@@ -1,131 +0,0 @@
package agentnetwork
import (
"encoding/json"
"fmt"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/catalog"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
sharedllm "github.com/netbirdio/netbird/shared/llm"
)
// costMeterConfig is the JSON shape the proxy-side cost_meter middleware
// expects (mirror-type pattern, same as routerConfig). The top-level
// "pricing" wrapper is the feature-detection signal: an old proxy's config
// struct ignores it as an unknown field, and a new proxy treats its
// absence as "old management" (skips every cost computation and warns).
type costMeterConfig struct {
Pricing *costMeterPricing `json:"pricing,omitempty"`
}
// costMeterPricing carries the full pricing table:
// - Defaults: surface ("openai"/"anthropic"/"bedrock") -> normalized
// model id -> rates. The full default table ships to every account —
// it is small (~10 KB) and keeps gateway-style providers (which
// enumerate no models) priced for every catalog model.
// - Providers: provider record id (matched against the
// llm.resolved_provider_id metadata llm_router stamps) -> normalized
// model id -> rates. Entries are fully materialized here at synth
// time — default cache rates already folded in — so the proxy does
// two map lookups and no merging.
type costMeterPricing struct {
Defaults map[string]map[string]pricing.Entry `json:"defaults,omitempty"`
Providers map[string]map[string]pricing.Entry `json:"providers,omitempty"`
}
// buildCostMeterConfigJSON assembles the cost_meter middleware config
// from the default pricing table plus the operator's stored per-provider
// model prices. Same orphan rule as the router: a provider no enabled
// policy authorises is unreachable, so its prices are not shipped.
//
// Overlay semantics per model row:
// - The row's model id is normalized exactly the way the proxy's
// request parser normalizes the ids it meters (bedrock ARN/region/
// version stripping, vertex "@version" stripping), so the per-record
// lookup key compares equal to llm.model at billing time.
// - The entry starts from the default entry for that model (when one
// exists) to inherit cache rates the operator didn't state.
// - Operator input/output overlay verbatim — including an explicit 0,
// which prices the model as free (self-hosted / internal endpoints)
// rather than silently reverting to list price.
// - Cache-rate pointers overlay only when non-nil: nil means "inherit
// the default", an explicit 0 means "no discount, bill this bucket
// at the input rate".
func buildCostMeterConfigJSON(providers []*types.Provider, groupIndex map[string][]string) ([]byte, error) {
cfg := costMeterConfig{Pricing: &costMeterPricing{
Defaults: pricing.DefaultTable(),
}}
perRecord := make(map[string]map[string]pricing.Entry)
for _, p := range providers {
if _, hasPolicy := groupIndex[p.ID]; !hasPolicy {
// Orphan: unreachable via the router, so unpriceable.
continue
}
if len(p.Models) == 0 {
// Gateway-style "claim every model" provider: the defaults
// table is its price list.
continue
}
entry, _ := catalog.Lookup(p.ProviderID)
models := make(map[string]pricing.Entry, len(p.Models))
for _, m := range p.Models {
id := normalizePricingModelID(p.ProviderID, m.ID)
if id == "" {
continue
}
if _, dup := models[id]; dup {
// First occurrence wins on post-normalization duplicates,
// matching providerModelIDs' dedup order for routing.
continue
}
models[id] = materializeEntry(entry.PricingSurfaces, id, m)
}
if len(models) > 0 {
perRecord[p.ID] = models
}
}
if len(perRecord) > 0 {
cfg.Pricing.Providers = perRecord
}
out, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal cost_meter middleware config: %w", err)
}
return out, nil
}
// normalizePricingModelID maps an operator-entered model id onto the
// normalized id the proxy's request parser emits as llm.model — the key
// the cost meter looks up at billing time.
func normalizePricingModelID(catalogProviderID, modelID string) string {
switch {
case catalog.IsBedrockPathStyle(catalogProviderID):
return sharedllm.NormalizeBedrockModel(modelID)
case catalog.IsVertexPathStyle(catalogProviderID):
return sharedllm.NormalizeVertexModel(modelID)
default:
return modelID
}
}
// materializeEntry folds the default entry for (surfaces, model) — when
// one exists — under the operator's stored prices, producing the fully
// materialized wire entry.
func materializeEntry(surfaces []string, normalizedID string, m types.ProviderModel) pricing.Entry {
e, _ := pricing.LookupDefault(surfaces, normalizedID) // zero Entry on miss
e.InputPer1k = m.InputPer1k
e.OutputPer1k = m.OutputPer1k
if m.CachedInputPer1k != nil {
e.CachedInputPer1k = *m.CachedInputPer1k
}
if m.CacheReadPer1k != nil {
e.CacheReadPer1k = *m.CacheReadPer1k
}
if m.CacheCreationPer1k != nil {
e.CacheCreationPer1k = *m.CacheCreationPer1k
}
return e
}

View File

@@ -1,105 +0,0 @@
package agentnetwork
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
)
func fptr(v float64) *float64 { return &v }
func decodeCostMeterConfig(t *testing.T, raw []byte) costMeterConfig {
t.Helper()
var cfg costMeterConfig
require.NoError(t, json.Unmarshal(raw, &cfg), "cost meter config must round-trip")
require.NotNil(t, cfg.Pricing, "pricing wrapper must be present")
return cfg
}
// TestBuildCostMeterConfig_BedrockModelNormalization: the operator may
// paste region-prefixed, versioned, or ARN-wrapped Bedrock ids; the
// per-record map must be keyed by the normalized id the request parser
// emits as llm.model, or the lookup never hits at billing time.
func TestBuildCostMeterConfig_BedrockModelNormalization(t *testing.T) {
bedrock := &types.Provider{
ID: "prov-bedrock",
ProviderID: "bedrock_api",
Enabled: true,
Models: []types.ProviderModel{
{ID: "eu.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 0.0033, OutputPer1k: 0.0165},
// Post-normalization duplicate of the row above under a
// different regional spelling — first occurrence wins.
{ID: "us.anthropic.claude-sonnet-4-5-20250929-v1:0", InputPer1k: 9.9, OutputPer1k: 9.9},
},
}
raw, err := buildCostMeterConfigJSON([]*types.Provider{bedrock}, map[string][]string{"prov-bedrock": {"grp"}})
require.NoError(t, err)
cfg := decodeCostMeterConfig(t, raw)
models := cfg.Pricing.Providers["prov-bedrock"]
require.Len(t, models, 1, "both spellings normalize to one model; first row wins")
e, ok := models["anthropic.claude-sonnet-4-5"]
require.True(t, ok, "key must be the normalized id the parser emits, not the operator's raw spelling")
assert.InDelta(t, 0.0033, e.InputPer1k, 1e-9, "first row's rate wins the dedup")
assert.InDelta(t, 0.0003, e.CacheReadPer1k, 1e-9, "cache read inherited from the bedrock default entry")
assert.InDelta(t, 0.00375, e.CacheCreationPer1k, 1e-9, "cache creation inherited from the bedrock default entry")
}
// TestBuildCostMeterConfig_CacheRateNilVsZero pins the pointer semantics:
// nil inherits the default cache rate, explicit 0 clears it (that bucket
// bills at the input rate on the proxy).
func TestBuildCostMeterConfig_CacheRateNilVsZero(t *testing.T) {
p := &types.Provider{
ID: "prov-oai",
ProviderID: "openai_api",
Enabled: true,
Models: []types.ProviderModel{
{ID: "gpt-4o", InputPer1k: 0.002, OutputPer1k: 0.008}, // nil → inherit 0.00125
{ID: "gpt-4o-mini", InputPer1k: 0.0001, OutputPer1k: 0.0005, CachedInputPer1k: fptr(0)}, // explicit 0 → no discount
{ID: "my-custom-ft", InputPer1k: 0.01, OutputPer1k: 0.02, CachedInputPer1k: fptr(0.005)}, // unknown model, explicit rate
},
}
raw, err := buildCostMeterConfigJSON([]*types.Provider{p}, map[string][]string{"prov-oai": {"grp"}})
require.NoError(t, err)
cfg := decodeCostMeterConfig(t, raw)
models := cfg.Pricing.Providers["prov-oai"]
assert.InDelta(t, 0.00125, models["gpt-4o"].CachedInputPer1k, 1e-9, "nil cache pointer inherits the default rate")
assert.Zero(t, models["gpt-4o-mini"].CachedInputPer1k, "explicit 0 overrides the default (0.000075) — bucket bills at input rate")
custom := models["my-custom-ft"]
assert.InDelta(t, 0.005, custom.CachedInputPer1k, 1e-9, "unknown model keeps the operator's explicit cache rate")
assert.Zero(t, custom.CacheReadPer1k, "no default to inherit for a model outside the catalog")
}
// TestBuildCostMeterConfig_OrphanAndGatewayProviders: an orphan (no
// authorising policy) is unreachable so its prices must not ship; a
// gateway with no model rows relies on the defaults table and gets no
// per-record entry.
func TestBuildCostMeterConfig_OrphanAndGatewayProviders(t *testing.T) {
orphan := &types.Provider{
ID: "prov-orphan",
ProviderID: "openai_api",
Enabled: true,
Models: []types.ProviderModel{{ID: "gpt-4o", InputPer1k: 1, OutputPer1k: 1}},
}
gateway := &types.Provider{
ID: "prov-litellm",
ProviderID: "litellm_proxy",
Enabled: true,
Models: []types.ProviderModel{},
}
raw, err := buildCostMeterConfigJSON(
[]*types.Provider{orphan, gateway},
map[string][]string{"prov-litellm": {"grp"}}, // orphan has no policy
)
require.NoError(t, err)
cfg := decodeCostMeterConfig(t, raw)
assert.NotContains(t, cfg.Pricing.Providers, "prov-orphan", "orphan provider prices must not ship")
assert.NotContains(t, cfg.Pricing.Providers, "prov-litellm", "empty-models gateway needs no per-record entry")
assert.NotEmpty(t, cfg.Pricing.Defaults["openai"], "defaults still ship so the gateway's catalog-model traffic is priced")
}

View File

@@ -33,17 +33,14 @@ func newSynthTestSettings() *types.Settings {
func newSynthTestProvider() *types.Provider {
return &types.Provider{
ID: "prov-1",
AccountID: testAccountID,
ProviderID: "openai_api",
Name: "OpenAI",
UpstreamURL: "https://api.openai.com",
APIKey: "sk-test-key",
Enabled: true,
// Prices deliberately differ from the catalog's gpt-5.4 rates
// (0.0025/0.015) so pricing tests can prove the operator's
// stored price overlays the catalog default.
Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02}},
ID: "prov-1",
AccountID: testAccountID,
ProviderID: "openai_api",
Name: "OpenAI",
UpstreamURL: "https://api.openai.com",
APIKey: "sk-test-key",
Enabled: true,
Models: []types.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.0025, OutputPer1k: 0.015}},
SessionPrivateKey: "test-priv-key",
SessionPublicKey: "test-pub-key",
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
@@ -217,27 +214,7 @@ func TestSynthesizeServices_HappyPath(t *testing.T) {
assert.Equal(t, middlewareIDCostMeter, mws[6].ID, "seventh middleware is the cost meter")
assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[6].Slot, "cost meter runs on_response")
var costCfg costMeterConfig
require.NoError(t, json.Unmarshal(mws[6].ConfigJSON, &costCfg), "cost meter config must unmarshal")
require.NotNil(t, costCfg.Pricing, "cost meter config must carry the pricing table — its absence tells the proxy management predates config-delivered pricing")
gpt4o, ok := costCfg.Pricing.Defaults["openai"]["gpt-4o"]
require.True(t, ok, "the full default table ships regardless of the account's providers")
assert.InDelta(t, 0.0025, gpt4o.InputPer1k, 1e-9, "default gpt-4o input rate comes from the catalog")
openaiPrices, ok := costCfg.Pricing.Providers[openai.ID]
require.True(t, ok, "operator-priced provider must have a per-record entry")
gpt54, ok := openaiPrices["gpt-5.4"]
require.True(t, ok, "operator's model row keys the per-record map")
assert.InDelta(t, 0.004, gpt54.InputPer1k, 1e-9, "operator input price overlays the catalog default (0.0025)")
assert.InDelta(t, 0.02, gpt54.OutputPer1k, 1e-9, "operator output price overlays the catalog default (0.015)")
assert.InDelta(t, 0.00025, gpt54.CachedInputPer1k, 1e-9, "cache rate the operator didn't state is inherited from the default entry")
opus, ok := costCfg.Pricing.Providers[anthropic.ID]["claude-opus-4-7"]
require.True(t, ok, "anthropic's model row keys its per-record map")
assert.Zero(t, opus.InputPer1k, "operator-stored zero prices ship verbatim — an explicit $0 model bills as free, it does not revert to list price")
assert.InDelta(t, 0.0005, opus.CacheReadPer1k, 1e-9, "cache rates still inherit from the default entry")
assert.Equal(t, []byte("{}"), mws[6].ConfigJSON, "cost meter carries an explicit empty config")
assert.Equal(t, middlewareIDLLMResponseParser, mws[7].ID, "eighth middleware is the response parser")
assert.Equal(t, rpservice.MiddlewareSlotOnResponse, mws[7].Slot, "response parser runs on_response")

View File

@@ -14,24 +14,10 @@ import (
// ProviderModel is one row in the provider's models list. The operator
// pins the per-1k input/output price for cost tracking; ID is the
// model identifier the upstream provider expects on the wire.
//
// The three cache rates are pointers because absence is meaningful: nil
// means "inherit NetBird's default rate for this model" (folded in at
// synthesis time), while an explicit 0 means "no discount — bill this
// cache bucket at the input rate".
type ProviderModel struct {
ID string `json:"id"`
InputPer1k float64 `json:"input_per_1k"`
OutputPer1k float64 `json:"output_per_1k"`
// CachedInputPer1k is the OpenAI-shape rate for cached prompt tokens
// (a subset of input tokens).
CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"`
// CacheReadPer1k is the Anthropic-shape rate for cache-read tokens
// (additive to input tokens).
CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"`
// CacheCreationPer1k is the Anthropic-shape rate for cache-creation
// tokens (additive to input tokens).
CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"`
}
// Provider is an Agent Network AI provider record persisted per account.
@@ -142,12 +128,9 @@ func (p *Provider) FromAPIRequest(req *api.AgentNetworkProviderRequest) {
if req.Models != nil {
for _, m := range *req.Models {
p.Models = append(p.Models, ProviderModel{
ID: m.Id,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k),
CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k),
CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k),
ID: m.Id,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
})
}
}
@@ -181,12 +164,9 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
models := make([]api.AgentNetworkProviderModel, 0, len(p.Models))
for _, m := range p.Models {
models = append(models, api.AgentNetworkProviderModel{
Id: m.ID,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
CachedInputPer1k: copyFloatPtr(m.CachedInputPer1k),
CacheReadPer1k: copyFloatPtr(m.CacheReadPer1k),
CacheCreationPer1k: copyFloatPtr(m.CacheCreationPer1k),
Id: m.ID,
InputPer1k: m.InputPer1k,
OutputPer1k: m.OutputPer1k,
})
}
created := p.CreatedAt
@@ -221,27 +201,11 @@ func (p *Provider) ToAPIResponse() *api.AgentNetworkProvider {
return resp
}
// copyFloatPtr returns a fresh pointer to the same value, or nil. Keeps
// stored models and API payloads from aliasing each other's rate fields.
func copyFloatPtr(v *float64) *float64 {
if v == nil {
return nil
}
out := *v
return &out
}
// Copy returns a deep copy of the provider.
func (p *Provider) Copy() *Provider {
clone := *p
if p.Models != nil {
clone.Models = make([]ProviderModel, len(p.Models))
for i, m := range p.Models {
m.CachedInputPer1k = copyFloatPtr(m.CachedInputPer1k)
m.CacheReadPer1k = copyFloatPtr(m.CacheReadPer1k)
m.CacheCreationPer1k = copyFloatPtr(m.CacheCreationPer1k)
clone.Models[i] = m
}
clone.Models = append([]ProviderModel(nil), p.Models...)
}
if p.ExtraValues != nil {
clone.ExtraValues = make(map[string]string, len(p.ExtraValues))

View File

@@ -103,12 +103,6 @@ func TestSynthesizedService_WireShape(t *testing.T) {
assert.Equal(t, middlewareIDCostMeter, mws[6].GetId(), "seventh middleware id")
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[6].GetSlot(), "cost meter slot")
var costCfg costMeterConfig
require.NoError(t, json.Unmarshal(mws[6].GetConfigJson(), &costCfg), "cost meter config JSON must decode from the wire")
require.NotNil(t, costCfg.Pricing, "the pricing table must travel on the wire — the proxy has no embedded price list to fall back to")
assert.NotEmpty(t, costCfg.Pricing.Defaults["openai"], "default table rides in every mapping")
assert.NotEmpty(t, costCfg.Pricing.Defaults["anthropic"], "default table covers all surfaces")
assert.NotEmpty(t, costCfg.Pricing.Defaults["bedrock"], "default table covers all surfaces")
assert.Equal(t, middlewareIDLLMResponseParser, mws[7].GetId(), "eighth middleware id")
assert.Equal(t, proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_RESPONSE, mws[7].GetSlot(), "response parser slot")

View File

@@ -55,8 +55,6 @@ type Config struct {
ReverseProxy ReverseProxy
AgentNetwork AgentNetwork
// disable default all-to-all policy
DisableDefaultPolicy bool
@@ -187,24 +185,6 @@ type StoreConfig struct {
Engine types.Engine
}
// AgentNetwork contains agent-network (LLM gateway) configuration.
type AgentNetwork struct {
// PricingDefaultsFile is the path to the YAML file holding the default
// LLM pricing table (defaults_llm_pricing.yaml). Empty falls back to
// probing <Datadir>/defaults_llm_pricing.yaml; with no file present the
// compiled-in defaults serve. Schema: surface ("openai"/"anthropic"/
// "bedrock") -> model -> rates in USD per 1k tokens (input_per_1k,
// output_per_1k, and the optional cached_input_per_1k /
// cache_read_per_1k / cache_creation_per_1k). File entries replace the
// compiled-in entry for the same surface+model whole; everything else
// keeps the compiled-in rates. The file is re-read periodically (mtime
// poll), and the live table feeds both the synthesizer (what proxies
// bill with) and the dashboard's catalog endpoint (what model rows
// prefill with). An explicitly configured path that fails to load
// fails startup; runtime reload errors keep the previous table.
PricingDefaultsFile string
}
// ReverseProxy contains reverse proxy configuration in front of management.
type ReverseProxy struct {
// TrustedHTTPProxies represents a list of trusted HTTP proxies by their IP prefixes.

View File

@@ -0,0 +1,38 @@
package llm
import (
"regexp"
"strings"
)
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
// version/throughput suffix of a Bedrock model id.
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
// prefix, and the version/throughput suffix from a Bedrock model id so it
// matches the catalog/pricing key, e.g.
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
// and the inference-profile ARN's last segment likewise. It is the single
// source of truth shared by the request parser (which normalizes the request
// model from the URL path) and the router (which normalizes the operator's
// registered Bedrock model ids so both sides compare equal).
func NormalizeBedrockModel(modelID string) string {
m := modelID
if strings.HasPrefix(m, "arn:") {
if i := strings.LastIndex(m, "/"); i >= 0 {
m = m[i+1:]
}
}
for _, p := range bedrockRegionPrefixes {
if strings.HasPrefix(m, p) {
m = m[len(p):]
break
}
}
return bedrockVersionSuffix.ReplaceAllString(m, "")
}

View File

@@ -11,8 +11,6 @@ func TestNormalizeBedrockModel(t *testing.T) {
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
"us.anthropic.claude-haiku-4-5": "anthropic.claude-haiku-4-5",
"us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8",
"apac.anthropic.claude-haiku-4-5-v1:0": "anthropic.claude-haiku-4-5",
"amazon.nova-2-lite-v1:0": "amazon.nova-2-lite",
"anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
"amazon.nova-pro-v1:0": "amazon.nova-pro",
@@ -23,14 +21,3 @@ func TestNormalizeBedrockModel(t *testing.T) {
require.Equal(t, want, NormalizeBedrockModel(in), "normalize %q", in)
}
}
func TestNormalizeVertexModel(t *testing.T) {
cases := map[string]string{
"claude-sonnet-4-5@20250929": "claude-sonnet-4-5",
"claude-haiku-4-5": "claude-haiku-4-5",
"gpt-4o@2024-08-06": "gpt-4o",
}
for in, want := range cases {
require.Equal(t, want, NormalizeVertexModel(in), "normalize %q", in)
}
}

View File

@@ -0,0 +1,59 @@
# Realistic-pricing starter for llm_observability. Drop this into the
# directory you point the proxy at via --plugin-data-dir, then reference it
# from the target's plugin config:
#
# plugins:
# - id: llm_observability
# enabled: true
# params:
# pricing_path: pricing.yaml
#
# Values are USD per 1_000 tokens. Public list prices drift; treat this as a
# starting point and keep your production copy current.
openai:
# GPT-5 family
gpt-5:
input_per_1k: 0.00125
output_per_1k: 0.01
gpt-5-mini:
input_per_1k: 0.00025
output_per_1k: 0.002
gpt-5-nano:
input_per_1k: 0.00005
output_per_1k: 0.0004
gpt-5.4:
input_per_1k: 0.00125
output_per_1k: 0.01
# GPT-4o family
gpt-4o:
input_per_1k: 0.0025
output_per_1k: 0.01
gpt-4o-mini:
input_per_1k: 0.00015
output_per_1k: 0.0006
# Embeddings
text-embedding-3-large:
input_per_1k: 0.00013
output_per_1k: 0
text-embedding-3-small:
input_per_1k: 0.00002
output_per_1k: 0
anthropic:
# Claude 4.x family
claude-opus-4-7:
input_per_1k: 0.015
output_per_1k: 0.075
claude-sonnet-4-7:
input_per_1k: 0.003
output_per_1k: 0.015
claude-sonnet-4-6:
input_per_1k: 0.003
output_per_1k: 0.015
claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
claude-haiku-4-5:
input_per_1k: 0.0008
output_per_1k: 0.004

View File

@@ -1,21 +0,0 @@
package llm
import (
sharedllm "github.com/netbirdio/netbird/shared/llm"
)
// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
// prefix, and the version/throughput suffix from a Bedrock model id so it
// matches the catalog/pricing key. Thin delegate to the shared implementation
// (shared/llm), which management also uses at synthesis time so both sides of
// the pricing / routing contract normalize identically.
func NormalizeBedrockModel(modelID string) string {
return sharedllm.NormalizeBedrockModel(modelID)
}
// NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id
// so it matches the catalog/pricing key. Thin delegate to shared/llm, kept
// beside NormalizeBedrockModel for the same contract reason.
func NormalizeVertexModel(modelID string) string {
return sharedllm.NormalizeVertexModel(modelID)
}

View File

@@ -0,0 +1,65 @@
package pricing
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestDefaultTable_FirstPartyModelCoverage guards the embedded defaults against
// silent drift/gaps: every metered first-party model the management catalog
// enumerates must resolve to a price, and a few rates that previously drifted
// are pinned to their LiteLLM-validated values. Keep this list in step with the
// catalog (management/server/agentnetwork/catalog) when adding models.
func TestDefaultTable_FirstPartyModelCoverage(t *testing.T) {
tbl := DefaultTable()
require.NotNil(t, tbl, "embedded default pricing table must load")
mustPrice := map[string][]string{
// openai parser covers openai_api, azure_openai_api, and mistral_api.
"openai": {
"gpt-5.5", "gpt-5.5-pro", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano",
"gpt-5.3-codex", "gpt-5.3-chat-latest", "o4-mini",
"gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini",
"gpt-4-turbo", "gpt-3.5-turbo", "gpt-35-turbo",
"text-embedding-3-large", "text-embedding-3-small",
"mistral-large-latest", "mistral-medium-3-5", "codestral-2508",
"ministral-8b-latest", "mistral-embed",
},
"anthropic": {
"claude-fable-5", "claude-opus-5", "claude-opus-4-8", "claude-opus-4-7", "claude-opus-4-6",
"claude-opus-4-1", "claude-sonnet-4-6", "claude-sonnet-4-5", "claude-haiku-4-5",
},
// bedrock keys are the normalized ids the request parser emits.
"bedrock": {
"anthropic.claude-opus-5", "anthropic.claude-opus-4-8", "anthropic.claude-opus-4-7", "anthropic.claude-opus-4-6",
"anthropic.claude-opus-4-1", "anthropic.claude-sonnet-4-6", "anthropic.claude-sonnet-4-5",
"anthropic.claude-haiku-4-5", "meta.llama3-3-70b-instruct",
"amazon.nova-pro", "amazon.nova-lite", "amazon.nova-micro", "amazon.nova-2-lite",
},
}
for provider, models := range mustPrice {
for _, m := range models {
_, ok := tbl.Cost(provider, m, 1000, 1000, 0, 0)
assert.True(t, ok, "%s/%s must be priced in the embedded defaults", provider, m)
}
}
// Pin per-direction rates independently (input-only then output-only) so a
// swap or skew of input<->output that preserves the combined total is still
// caught — these are rates that previously drifted or are easy to mis-enter.
in, ok := tbl.Cost("openai", "gpt-5.4", 1000, 0, 0, 0)
require.True(t, ok)
assert.InDelta(t, 0.0025, in, 1e-9, "gpt-5.4 input = 0.0025 per 1k")
out, ok := tbl.Cost("openai", "gpt-5.4", 0, 1000, 0, 0)
require.True(t, ok)
assert.InDelta(t, 0.015, out, 1e-9, "gpt-5.4 output = 0.015 per 1k")
in, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 1000, 0, 0, 0)
require.True(t, ok)
assert.InDelta(t, 0.003, in, 1e-9, "bedrock sonnet-4-5 input = 0.003 per 1k")
out, ok = tbl.Cost("bedrock", "anthropic.claude-sonnet-4-5", 0, 1000, 0, 0)
require.True(t, ok)
assert.InDelta(t, 0.015, out, 1e-9, "bedrock sonnet-4-5 output = 0.015 per 1k")
}

View File

@@ -1,196 +1,66 @@
# Default LLM pricing used by NetBird's Agent Network cost metering.
# GENERATED from the management catalog — do not edit this file in the
# repository; regenerate with:
# Embedded default pricing for llm_observability. Compiled into the proxy
# binary via go:embed in pricing.go; cost annotation works out of the box
# without any operator action.
#
# go generate ./management/internals/modules/agentnetwork/pricing
# Operators override entries by dropping a pricing.yaml into --plugin-data-dir
# (or whichever basename is given via params.pricing_path). The override file
# only needs entries the operator wants to change; missing entries fall
# through to these defaults.
#
# Operators: copy this file to <datadir>/defaults_llm_pricing.yaml (or
# any path configured via management.json:
# Values are USD per 1_000 tokens. Public list prices drift; ship a fresh
# binary or override individual entries via the override file as needed.
#
# { "AgentNetwork": { "PricingDefaultsFile": "/path/defaults_llm_pricing.yaml" } }
#
# ) and adjust the entries you want to change. Management re-reads the
# file periodically (mtime poll, every minute): the live table feeds the
# proxies' cost metering and the dashboard's model-price prefill, so
# edits apply without a restart. Your file only needs the entries you
# want to change — but each entry REPLACES the built-in entry for that
# surface+model whole, so repeat the cache rates you want to keep.
# Unknown fields and negative or non-finite rates are rejected: at
# startup that fails boot (for an explicitly configured path); at
# runtime the previous table is kept and a warning is logged. Deleting
# the file reverts to the built-in defaults below.
#
# Top-level keys are pricing surfaces — the parser shape requests are
# metered under: "openai" (also Azure, Mistral, and OpenAI-compatible
# gateways), "anthropic" (also Anthropic-on-Vertex), "bedrock"
# (normalized ids, e.g. anthropic.claude-sonnet-4-5). Model keys must be
# the normalized id the proxy meters (version/region suffixes stripped).
#
# Values are USD per 1_000 tokens. Optional cache fields:
# cached_input_per_1k OpenAI shape: rate for cached prompt tokens
# (a SUBSET of input tokens). Absent -> cached
# portion bills at input_per_1k.
# cache_read_per_1k Anthropic shape: rate for cache_read tokens
# (ADDITIVE to input). Absent -> input rate.
# cache_creation_per_1k Anthropic shape: rate for cache_creation
# tokens (ADDITIVE to input). Absent -> input
# rate.
anthropic:
claude-fable-5:
input_per_1k: 0.01
output_per_1k: 0.05
cache_read_per_1k: 0.001
cache_creation_per_1k: 0.0125
claude-haiku-4-5:
input_per_1k: 0.001
output_per_1k: 0.005
cache_read_per_1k: 0.0001
cache_creation_per_1k: 0.00125
claude-opus-4-1:
input_per_1k: 0.015
output_per_1k: 0.075
cache_read_per_1k: 0.0015
cache_creation_per_1k: 0.01875
claude-opus-4-6:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-7:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-8:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-5:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
claude-sonnet-4-6:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
kimi-k3:
input_per_1k: 0.003
output_per_1k: 0.015
cached_input_per_1k: 0.0003
cache_read_per_1k: 0.0003
"kimi-k3[1m]":
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
bedrock:
amazon.nova-2-lite:
input_per_1k: 0.0003
output_per_1k: 0.0025
amazon.nova-lite:
input_per_1k: 0.00006
output_per_1k: 0.00024
amazon.nova-micro:
input_per_1k: 0.000035
output_per_1k: 0.00014
amazon.nova-pro:
input_per_1k: 0.0008
output_per_1k: 0.0032
anthropic.claude-haiku-4-5:
input_per_1k: 0.001
output_per_1k: 0.005
cache_read_per_1k: 0.0001
cache_creation_per_1k: 0.00125
anthropic.claude-opus-4-1:
input_per_1k: 0.015
output_per_1k: 0.075
cache_read_per_1k: 0.0015
cache_creation_per_1k: 0.01875
anthropic.claude-opus-4-6:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-7:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-8:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-5:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
anthropic.claude-sonnet-4-6:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
meta.llama3-3-70b-instruct:
input_per_1k: 0.00072
output_per_1k: 0.00072
# Optional cache fields:
# cached_input_per_1k OpenAI: rate for prompt_tokens_details.cached_tokens
# (a SUBSET of prompt_tokens). Typically 0.5x input.
# Absent → cached portion bills at input_per_1k.
# cache_read_per_1k Anthropic: rate for cache_read_input_tokens
# (ADDITIVE to input_tokens). Typically 0.1x input.
# Absent → cache reads bill at input_per_1k.
# cache_creation_per_1k Anthropic: rate for cache_creation_input_tokens
# (ADDITIVE to input_tokens). Typically 1.25x input.
# Absent → cache writes bill at input_per_1k.
openai:
codestral-2508:
input_per_1k: 0.0003
output_per_1k: 0.0009
codestral-latest:
input_per_1k: 0.001
output_per_1k: 0.003
devstral-medium-latest:
input_per_1k: 0.0004
output_per_1k: 0.002
devstral-small-latest:
input_per_1k: 0.0001
output_per_1k: 0.0003
gpt-3.5-turbo:
input_per_1k: 0.0005
output_per_1k: 0.0015
gpt-35-turbo:
input_per_1k: 0.0005
output_per_1k: 0.0015
gpt-4-turbo:
input_per_1k: 0.01
# OpenAI + OpenAI-compatible providers (openai_api, azure_openai_api,
# mistral_api, and the openai-parser gateways) all emit llm.provider="openai",
# so their models are priced here. Kept in sync with the management catalog;
# rates cross-checked against LiteLLM model_prices_and_context_window.json.
# GPT-5.x family — cache reads 10% of input (0.1x).
gpt-5.5:
input_per_1k: 0.005
output_per_1k: 0.03
gpt-4.1:
input_per_1k: 0.002
output_per_1k: 0.008
cached_input_per_1k: 0.0005
gpt-4.1-mini:
input_per_1k: 0.0004
output_per_1k: 0.0016
cached_input_per_1k: 0.0001
gpt-4.1-nano:
input_per_1k: 0.0001
output_per_1k: 0.0004
cached_input_per_1k: 0.000025
gpt-4o:
gpt-5.5-pro:
input_per_1k: 0.03
output_per_1k: 0.18
cached_input_per_1k: 0.003
gpt-5.4:
input_per_1k: 0.0025
output_per_1k: 0.01
cached_input_per_1k: 0.00125
gpt-4o-mini:
input_per_1k: 0.00015
output_per_1k: 0.0006
output_per_1k: 0.015
cached_input_per_1k: 0.00025
gpt-5.4-pro:
input_per_1k: 0.03
output_per_1k: 0.18
cached_input_per_1k: 0.003
gpt-5.4-mini:
input_per_1k: 0.00075
output_per_1k: 0.0045
cached_input_per_1k: 0.000075
gpt-5.4-nano:
input_per_1k: 0.0002
output_per_1k: 0.00125
cached_input_per_1k: 0.00002
gpt-5.3-codex:
input_per_1k: 0.00175
output_per_1k: 0.014
cached_input_per_1k: 0.000175
gpt-5.3-chat-latest:
input_per_1k: 0.00175
output_per_1k: 0.014
cached_input_per_1k: 0.000175
# GPT-5 (2025) family — kept for gateway requests using the unsuffixed ids.
gpt-5:
input_per_1k: 0.00125
output_per_1k: 0.01
@@ -203,80 +73,226 @@ openai:
input_per_1k: 0.00005
output_per_1k: 0.0004
cached_input_per_1k: 0.000005
gpt-5.3-chat-latest:
input_per_1k: 0.00175
output_per_1k: 0.014
cached_input_per_1k: 0.000175
gpt-5.3-codex:
input_per_1k: 0.00175
output_per_1k: 0.014
cached_input_per_1k: 0.000175
gpt-5.4:
input_per_1k: 0.0025
output_per_1k: 0.015
cached_input_per_1k: 0.00025
gpt-5.4-mini:
input_per_1k: 0.00075
output_per_1k: 0.0045
cached_input_per_1k: 0.000075
gpt-5.4-nano:
input_per_1k: 0.0002
output_per_1k: 0.00125
cached_input_per_1k: 0.00002
gpt-5.4-pro:
input_per_1k: 0.03
output_per_1k: 0.18
cached_input_per_1k: 0.003
gpt-5.5:
input_per_1k: 0.005
output_per_1k: 0.03
cached_input_per_1k: 0.0005
gpt-5.5-pro:
input_per_1k: 0.03
output_per_1k: 0.18
cached_input_per_1k: 0.003
kimi-k3:
input_per_1k: 0.003
output_per_1k: 0.015
cached_input_per_1k: 0.0003
cache_read_per_1k: 0.0003
magistral-medium-latest:
input_per_1k: 0.002
output_per_1k: 0.005
magistral-small-latest:
input_per_1k: 0.0005
output_per_1k: 0.0015
ministral-3-14b-2512:
input_per_1k: 0.0002
output_per_1k: 0.0002
ministral-3-3b-2512:
input_per_1k: 0.0001
output_per_1k: 0.0001
ministral-8b-latest:
input_per_1k: 0.00015
output_per_1k: 0.00015
mistral-embed:
input_per_1k: 0.0001
output_per_1k: 0
mistral-large-latest:
input_per_1k: 0.0005
output_per_1k: 0.0015
mistral-medium-3-5:
input_per_1k: 0.0015
output_per_1k: 0.0075
mistral-medium-latest:
input_per_1k: 0.0004
output_per_1k: 0.002
mistral-small-latest:
input_per_1k: 0.00006
output_per_1k: 0.00018
o4-mini:
input_per_1k: 0.0011
output_per_1k: 0.0044
cached_input_per_1k: 0.000275
# GPT-4.1 family — cache reads 25% of input.
gpt-4.1:
input_per_1k: 0.002
output_per_1k: 0.008
cached_input_per_1k: 0.0005
gpt-4.1-mini:
input_per_1k: 0.0004
output_per_1k: 0.0016
cached_input_per_1k: 0.0001
gpt-4.1-nano:
input_per_1k: 0.0001
output_per_1k: 0.0004
cached_input_per_1k: 0.000025
# GPT-4o family — cache reads 50% of input (0.5x).
gpt-4o:
input_per_1k: 0.0025
output_per_1k: 0.01
cached_input_per_1k: 0.00125
gpt-4o-mini:
input_per_1k: 0.00015
output_per_1k: 0.0006
cached_input_per_1k: 0.000075
# Older GPT — no prompt caching.
gpt-4-turbo:
input_per_1k: 0.01
output_per_1k: 0.03
gpt-3.5-turbo:
input_per_1k: 0.0005
output_per_1k: 0.0015
gpt-35-turbo: # Azure deployment alias of gpt-3.5-turbo
input_per_1k: 0.0005
output_per_1k: 0.0015
# Embeddings — no caching, no output tokens.
text-embedding-3-large:
input_per_1k: 0.00013
output_per_1k: 0
text-embedding-3-small:
input_per_1k: 0.00002
output_per_1k: 0
# Mistral (mistral_api) — routed via the openai parser; no prompt caching.
mistral-large-latest:
input_per_1k: 0.0005
output_per_1k: 0.0015
mistral-medium-latest:
input_per_1k: 0.0004
output_per_1k: 0.002
mistral-medium-3-5:
input_per_1k: 0.0015
output_per_1k: 0.0075
mistral-small-latest:
input_per_1k: 0.00006
output_per_1k: 0.00018
magistral-medium-latest:
input_per_1k: 0.002
output_per_1k: 0.005
magistral-small-latest:
input_per_1k: 0.0005
output_per_1k: 0.0015
devstral-medium-latest:
input_per_1k: 0.0004
output_per_1k: 0.002
devstral-small-latest:
input_per_1k: 0.0001
output_per_1k: 0.0003
codestral-2508:
input_per_1k: 0.0003
output_per_1k: 0.0009
codestral-latest:
input_per_1k: 0.001
output_per_1k: 0.003
ministral-3-14b-2512:
input_per_1k: 0.0002
output_per_1k: 0.0002
ministral-8b-latest:
input_per_1k: 0.00015
output_per_1k: 0.00015
ministral-3-3b-2512:
input_per_1k: 0.0001
output_per_1k: 0.0001
mistral-embed:
input_per_1k: 0.0001
output_per_1k: 0
# Kimi / Moonshot AI (kimi_api) — OpenAI-compatible /v1 endpoint. Moonshot
# reports cache hits OpenAI-style when present; cached input is 10% of
# input ($0.30 vs $3.00 per MTok). kimi-k3 is the only model the platform
# serves newer accounts (K2-era ids and kimi-latest 404), matching the
# management catalog.
kimi-k3:
input_per_1k: 0.003
output_per_1k: 0.015
cached_input_per_1k: 0.0003
anthropic:
# Claude 4.x family — cache reads ≈10% of input, cache writes ≈125% of input.
# Pricing source: Anthropic's current published rates per million tokens,
# divided by 1000 for the per-1k figures stored here.
claude-fable-5:
input_per_1k: 0.010
output_per_1k: 0.050
cache_read_per_1k: 0.001
cache_creation_per_1k: 0.0125
claude-opus-5:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-8:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-7:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-6:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
claude-opus-4-1:
input_per_1k: 0.015
output_per_1k: 0.075
cache_read_per_1k: 0.0015
cache_creation_per_1k: 0.01875
claude-sonnet-4-6:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
claude-haiku-4-5:
input_per_1k: 0.001
output_per_1k: 0.005
cache_read_per_1k: 0.0001
cache_creation_per_1k: 0.00125
# Kimi / Moonshot AI (kimi_api) via the Anthropic-compatible endpoint
# (/anthropic/v1/messages — the official Claude Code setup). Same rates
# as the OpenAI-shape entry above. "kimi-k3[1m]" is the model id some
# Claude Code guides set for the 1M-context alias; priced identically so
# cost metering doesn't silently skip those requests.
kimi-k3:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
"kimi-k3[1m]":
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
bedrock:
# AWS Bedrock model ids, normalised by the request parser (cross-region
# inference-profile prefix + version/throughput suffix stripped), e.g.
# eu.anthropic.claude-sonnet-4-5-20250929-v1:0 -> anthropic.claude-sonnet-4-5.
# Anthropic-on-Bedrock keeps the additive cache buckets (read ≈0.1x input,
# write ≈1.25x input); Nova / Llama report no cache, so cost is input+output.
anthropic.claude-opus-5:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-8:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-7:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-6:
input_per_1k: 0.005
output_per_1k: 0.025
cache_read_per_1k: 0.0005
cache_creation_per_1k: 0.00625
anthropic.claude-opus-4-1:
input_per_1k: 0.015
output_per_1k: 0.075
cache_read_per_1k: 0.0015
cache_creation_per_1k: 0.01875
anthropic.claude-sonnet-4-6:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
anthropic.claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
anthropic.claude-haiku-4-5:
input_per_1k: 0.001
output_per_1k: 0.005
cache_read_per_1k: 0.0001
cache_creation_per_1k: 0.00125
meta.llama3-3-70b-instruct:
input_per_1k: 0.00072
output_per_1k: 0.00072
amazon.nova-2-lite:
input_per_1k: 0.0003
output_per_1k: 0.0025
amazon.nova-pro:
input_per_1k: 0.0008
output_per_1k: 0.0032
amazon.nova-lite:
input_per_1k: 0.00006
output_per_1k: 0.00024
amazon.nova-micro:
input_per_1k: 0.000035
output_per_1k: 0.00014

View File

@@ -1,30 +1,102 @@
// Package pricing implements the pricing table and cost formula the
// cost_meter middleware uses to convert LLM token usage into a USD cost
// estimate. The table's content arrives from the management server inside
// cost_meter's middleware config (synthesized from the catalog plus the
// operator's stored per-provider prices) — the proxy carries no embedded
// price list. Price updates ride the ordinary mapping push: a chain
// rebuild constructs a fresh table, so there is nothing to reload.
// Package pricing implements the embedded-default + override pricing table
// shared by middleware that converts LLM token usage into a USD cost
// estimate. The table is hot-reloadable from a basename under the proxy
// data directory; missing override files keep the embedded defaults so
// cost annotation works without operator action.
package pricing
import (
"bytes"
"context"
_ "embed"
"errors"
"fmt"
"io"
"io/fs"
"math"
"path/filepath"
"regexp"
"strings"
"sync"
"sync/atomic"
"time"
log "github.com/sirupsen/logrus"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"gopkg.in/yaml.v3"
)
//go:embed defaults_pricing.yaml
var defaultPricingYAML []byte
var (
defaultTableOnce sync.Once
defaultTablePtr *Table
)
// DefaultTable returns the pricing table embedded in the binary. The result
// is parsed once and shared; callers must not mutate the returned value.
// Cost annotation works without any operator action because every loader
// starts with this table.
func DefaultTable() *Table {
defaultTableOnce.Do(func() {
t, err := parsePricingBytes(defaultPricingYAML)
if err != nil {
panic(fmt.Sprintf("llmobs: embedded default pricing failed to parse: %v", err))
}
defaultTablePtr = t
})
return defaultTablePtr
}
// mergeOver returns a new Table containing every entry from base, with any
// matching entry from overlay replacing the base value. Either argument may
// be nil. Result is a fresh allocation so callers can mutate / Store safely.
func mergeOver(base, overlay *Table) *Table {
if overlay == nil || len(overlay.entries) == 0 {
return base
}
if base == nil || len(base.entries) == 0 {
return overlay
}
out := make(map[string]map[string]Entry, len(base.entries))
for provider, models := range base.entries {
inner := make(map[string]Entry, len(models))
for model, e := range models {
inner[model] = e
}
out[provider] = inner
}
for provider, models := range overlay.entries {
inner, ok := out[provider]
if !ok {
inner = make(map[string]Entry, len(models))
out[provider] = inner
}
for model, e := range models {
inner[model] = e
}
}
return &Table{entries: out}
}
// Entry is a single model's input and output pricing, expressed in USD per
// 1000 tokens.
//
// CachedInputPer1K applies to OpenAI's cached prompt tokens, which are a
// subset of input_tokens — when set, the cached portion is billed at this
// rate and the non-cached remainder at InputPer1K. Zero means "no discount
// configured", and cached tokens are billed at InputPer1K.
// configured", and cached tokens are billed at InputPer1K (matches current
// behaviour where cached counts weren't extracted at all).
//
// CacheReadPer1K and CacheCreationPer1K apply to Anthropic's two prompt-
// cache fields, which are additive to input_tokens: cache_read is the
// cheaper read-from-cache rate, cache_creation is the more expensive
// write-to-cache rate. Zero means "no rate configured" and the
// corresponding token bucket is billed at InputPer1K.
// corresponding token bucket is billed at InputPer1K. This is more
// accurate than today's behaviour, where Anthropic's cache tokens are
// ignored and not charged at all.
type Entry struct {
InputPer1K float64
OutputPer1K float64
@@ -33,102 +105,33 @@ type Entry struct {
CacheCreationPer1K float64
}
// EntryJSON is the wire shape of a pricing entry inside cost_meter's
// middleware config. Field names are the management→proxy contract; the
// management synthesizer marshals the same names (its pricing.Entry).
type EntryJSON struct {
InputPer1K float64 `json:"input_per_1k"`
OutputPer1K float64 `json:"output_per_1k"`
CachedInputPer1K float64 `json:"cached_input_per_1k"`
CacheReadPer1K float64 `json:"cache_read_per_1k"`
CacheCreationPer1K float64 `json:"cache_creation_per_1k"`
}
// Table is a provider-surface-to-model pricing lookup. Instances are
// immutable once built; a mapping update builds a whole new middleware
// instance (and with it a new table) rather than mutating this one.
// Table is a provider-to-model pricing lookup. Instances are immutable once
// built and are swapped atomically by Loader.
type Table struct {
entries map[string]map[string]Entry
}
// NewEntries validates and converts a wire-shape map (surface-or-record ->
// model -> rates) into the internal representation. Every rate must be a
// finite, non-negative USD amount; a violation is returned as an error so
// a corrupt config fails the chain build loudly instead of mispricing.
// Management validates the same constraints at its API boundary, so this
// is defense-in-depth. Nil input yields an empty (never-matching) map.
func NewEntries(raw map[string]map[string]EntryJSON) (map[string]map[string]Entry, error) {
out := make(map[string]map[string]Entry, len(raw))
for outer, models := range raw {
inner := make(map[string]Entry, len(models))
for model, e := range models {
for field, v := range map[string]float64{
"input_per_1k": e.InputPer1K,
"output_per_1k": e.OutputPer1K,
"cached_input_per_1k": e.CachedInputPer1K,
"cache_read_per_1k": e.CacheReadPer1K,
"cache_creation_per_1k": e.CacheCreationPer1K,
} {
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return nil, fmt.Errorf("pricing %s/%s: %s must be a finite, non-negative rate, got %v", outer, model, field, v)
}
}
// EntryJSON and Entry are field-identical (tags aside), so a
// direct conversion carries all five rates.
inner[model] = Entry(e)
}
out[outer] = inner
}
return out, nil
}
// NewTable builds an immutable Table from the wire-shape defaults map.
// See NewEntries for validation semantics.
func NewTable(raw map[string]map[string]EntryJSON) (*Table, error) {
entries, err := NewEntries(raw)
if err != nil {
return nil, err
}
return &Table{entries: entries}, nil
}
// Lookup returns the entry for the given provider surface and model.
func (t *Table) Lookup(provider, model string) (Entry, bool) {
if t == nil {
return Entry{}, false
}
byModel, ok := t.entries[provider]
if !ok {
return Entry{}, false
}
e, ok := byModel[model]
return e, ok
}
// Has reports whether the provider/model pair is present in the table.
func (t *Table) Has(provider, model string) bool {
_, ok := t.Lookup(provider, model)
return ok
}
// Cost returns the estimated USD cost for the given token counts. ok is
// false when the provider or model is not present in the table; the caller
// can still emit token metrics with a model=unknown label.
//
// Provider-shape semantics for cached / cache-creation counts:
//
// - OpenAI: cachedInput is a SUBSET of inTokens. The cached portion is
// billed at CachedInputPer1K (or InputPer1K when no override), and the
// non-cached remainder of inTokens at InputPer1K. cacheCreation is
// ignored (OpenAI has no analogue).
// - Anthropic: cachedInput (cache_read) and cacheCreation are ADDITIVE to
// inTokens. The three buckets are billed at CacheReadPer1K,
// CacheCreationPer1K, and InputPer1K respectively, each falling back
// to InputPer1K when the corresponding rate is zero.
// - Other providers: cached and cacheCreation are ignored; cost is
// inTokens*InputPer1K + outTokens*OutputPer1K.
func (t *Table) Cost(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (float64, bool) {
c, ok := t.Costs(provider, model, inTokens, outTokens, cachedInput, cacheCreation)
return c.TotalUSD, ok
}
// Costs returns the estimated USD cost split for the given token counts.
// The provider surface selects the cache formula; see EntryCosts.
func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) {
entry, ok := t.Lookup(provider, model)
if !ok {
return Costs{}, false
}
return EntryCosts(entry, provider, inTokens, outTokens, cachedInput, cacheCreation), true
}
// Costs is a per-request cost split. The four per-bucket fields are the base
// of the breakdown — one per token bucket the provider bills separately — and
// the two aggregates are derived from them:
@@ -162,25 +165,9 @@ func newCosts(input, cachedInput, cacheCreation, output float64) Costs {
}
}
// EntryCosts computes the USD cost split for the given entry and token
// counts. The surface (the llm.provider value the request parser stamps)
// selects the cache formula; the entry may come from the surface-keyed
// defaults table or from a per-provider-record override — the math is
// identical either way.
//
// Provider-shape semantics for cached / cache-creation counts:
//
// - "openai": cachedInput is a SUBSET of inTokens. The cached portion is
// billed at CachedInputPer1K (or InputPer1K when no override), and the
// non-cached remainder of inTokens at InputPer1K. cacheCreation is
// ignored (OpenAI has no analogue).
// - "anthropic", "bedrock": cachedInput (cache_read) and cacheCreation are
// ADDITIVE to inTokens. The three buckets are billed at CacheReadPer1K,
// CacheCreationPer1K, and InputPer1K respectively, each falling back
// to InputPer1K when the corresponding rate is zero.
// - Other surfaces: cached and cacheCreation are ignored; cost is
// inTokens*InputPer1K + outTokens*OutputPer1K.
func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, cacheCreation int64) Costs {
// Costs returns the estimated USD cost split for the given token counts, with
// the same semantics as Cost.
func (t *Table) Costs(provider, model string, inTokens, outTokens, cachedInput, cacheCreation int64) (Costs, bool) {
// Clamp negatives to zero before any pricing math so a malformed
// upstream count can never produce a negative cost.
if inTokens < 0 {
@@ -195,8 +182,19 @@ func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, c
if cacheCreation < 0 {
cacheCreation = 0
}
if t == nil {
return Costs{}, false
}
byModel, ok := t.entries[provider]
if !ok {
return Costs{}, false
}
entry, ok := byModel[model]
if !ok {
return Costs{}, false
}
output := (float64(outTokens) / 1000.0) * entry.OutputPer1K
switch surface {
switch provider {
case "openai":
// cachedInput is a subset of inTokens; clamp so a malformed
// upstream (cached > total) can't produce a negative remainder.
@@ -210,7 +208,7 @@ func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, c
}
nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K
cached := float64(clamped) / 1000.0 * cachedRate
return newCosts(nonCached, cached, 0, output)
return newCosts(nonCached, cached, 0, output), true
case "anthropic", "bedrock":
// Bedrock-Anthropic returns the same additive cache buckets as
// first-party Anthropic; non-Anthropic Bedrock models simply report
@@ -226,9 +224,266 @@ func EntryCosts(entry Entry, surface string, inTokens, outTokens, cachedInput, c
input := float64(inTokens) / 1000.0 * entry.InputPer1K
read := float64(cachedInput) / 1000.0 * readRate
create := float64(cacheCreation) / 1000.0 * createRate
return newCosts(input, read, create, output)
return newCosts(input, read, create, output), true
default:
input := float64(inTokens) / 1000.0 * entry.InputPer1K
return newCosts(input, 0, 0, output)
return newCosts(input, 0, 0, output), true
}
}
// Has reports whether the provider/model pair is present in the table.
func (t *Table) Has(provider, model string) bool {
if t == nil {
return false
}
byModel, ok := t.entries[provider]
if !ok {
return false
}
_, ok = byModel[model]
return ok
}
// pricingFile mirrors the on-disk YAML schema. Keys are provider names; the
// nested map keys are model names.
type pricingFile map[string]map[string]struct {
InputPer1K float64 `yaml:"input_per_1k"`
OutputPer1K float64 `yaml:"output_per_1k"`
CachedInputPer1K float64 `yaml:"cached_input_per_1k"`
CacheReadPer1K float64 `yaml:"cache_read_per_1k"`
CacheCreationPer1K float64 `yaml:"cache_creation_per_1k"`
}
const (
// ReloadInterval is the mtime-poll cadence for the background reloader.
ReloadInterval = 30 * time.Second
// errorBackoff bounds how often the loader logs a repeated parse error.
errorBackoff = 5 * time.Minute
)
var basenameRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`)
// Loader is a confined, hot-reloadable pricing table reader. Construction
// must succeed against the target file; subsequent reload failures keep the
// previously-loaded table so callers never observe a blank price list.
type Loader struct {
baseDir string
fullPath string
pluginID string
table atomic.Pointer[Table]
mtime atomic.Int64
failures metric.Int64Counter
interval time.Duration
}
// NewLoader returns a pricing loader that overlays an optional file-based
// table on top of the embedded defaults. Missing override file, baseDir, or
// relPath is not an error: the loader keeps the embedded defaults so cost
// metadata is still emitted for known models.
//
// Errors:
// - bad basename, traversal segment, or absolute relPath are rejected so a
// misconfigured target surfaces immediately.
// - permission errors and YAML parse errors keep the defaults but log a
// warning; cost annotation does not silently break.
//
// failures is optional; pass nil in tests that do not care about
// reload-failure telemetry.
func NewLoader(baseDir, relPath, pluginID string, failures metric.Int64Counter) (*Loader, error) {
defaults := DefaultTable()
l := &Loader{
baseDir: baseDir,
pluginID: pluginID,
failures: failures,
}
l.table.Store(defaults)
if strings.TrimSpace(baseDir) == "" || strings.TrimSpace(relPath) == "" {
return l, nil
}
full, err := resolveMiddlewareDataPath(baseDir, relPath)
if err != nil {
return nil, err
}
l.fullPath = full
overlay, mtime, err := loadPricing(full)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// Override file is optional. Defaults already stored.
return l, nil
}
// Symlink rejection, oversize file, parse failure, permission errors
// — surface so a misconfigured operator sees the problem instead of
// silently running with stale defaults.
return nil, fmt.Errorf("load pricing %s: %w", full, err)
}
l.table.Store(mergeOver(defaults, overlay))
l.mtime.Store(mtime.UnixNano())
return l, nil
}
// Get returns the current pricing table. The returned pointer is immutable;
// callers must not mutate its contents.
func (l *Loader) Get() *Table {
if l == nil {
return nil
}
return l.table.Load()
}
// WatchesFile reports whether this loader is bound to an override file on
// disk. False for defaults-only loaders (no operator override given).
// Callers use this to decide whether to spawn the mtime-poll goroutine.
func (l *Loader) WatchesFile() bool {
if l == nil {
return false
}
return l.fullPath != ""
}
// SetReloadInterval overrides the mtime-poll cadence used by Reload. Calls
// after Reload has started have no effect on the running loop. Intended for
// tests; production code uses the default ReloadInterval.
func (l *Loader) SetReloadInterval(d time.Duration) {
if l == nil || d <= 0 {
return
}
l.interval = d
}
// Reload runs a polling loop that checks the pricing file mtime every
// ReloadInterval (or the value passed to SetReloadInterval). Returns when
// ctx is cancelled.
func (l *Loader) Reload(ctx context.Context) {
if l == nil {
return
}
interval := l.interval
if interval <= 0 {
interval = ReloadInterval
}
t := time.NewTicker(interval)
defer t.Stop()
var lastErrAt time.Time
for {
select {
case <-ctx.Done():
return
case <-t.C:
if err := l.reload(); err != nil {
if l.failures != nil {
l.failures.Add(ctx, 1, metric.WithAttributes(
attribute.String("plugin", l.pluginID),
))
}
now := time.Now()
if now.Sub(lastErrAt) >= errorBackoff {
log.Warnf("llmobs: pricing reload failed for %s: %v", l.fullPath, err)
lastErrAt = now
}
}
}
}
}
// reload performs a single-shot mtime check and reload. The reloaded
// override file is merged on top of the embedded defaults; missing override
// (e.g. operator deleted the file) is not an error and reverts to defaults.
func (l *Loader) reload() error {
if l.fullPath == "" {
// Defaults-only loader; nothing on disk to reload.
return nil
}
mtime, err := statMtime(l.fullPath)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
// File was removed since startup. Drop back to defaults and
// reset mtime so a future re-creation triggers a reload.
l.table.Store(DefaultTable())
l.mtime.Store(0)
return nil
}
return err
}
if mtime.UnixNano() == l.mtime.Load() {
return nil
}
overlay, newMtime, err := loadPricing(l.fullPath)
if err != nil {
return err
}
l.table.Store(mergeOver(DefaultTable(), overlay))
l.mtime.Store(newMtime.UnixNano())
return nil
}
// resolveMiddlewareDataPath validates relPath is a safe basename and resolves
// it under baseDir. An additional cleaned-prefix check guards against
// CVE-style edge cases where Join is used with trailing path segments.
func resolveMiddlewareDataPath(baseDir, relPath string) (string, error) {
if strings.TrimSpace(baseDir) == "" {
return "", errors.New("middleware-data-dir is not configured")
}
if relPath == "" {
return "", errors.New("pricing path is empty")
}
if !basenameRegex.MatchString(relPath) {
return "", fmt.Errorf("pricing path %q is not a safe basename", relPath)
}
if filepath.IsAbs(relPath) {
return "", fmt.Errorf("pricing path %q must be a basename, not absolute", relPath)
}
cleanBase, err := filepath.Abs(filepath.Clean(baseDir))
if err != nil {
return "", fmt.Errorf("resolve middleware-data-dir: %w", err)
}
full := filepath.Join(cleanBase, relPath)
cleanedFull := filepath.Clean(full)
if !strings.HasPrefix(cleanedFull, cleanBase+string(filepath.Separator)) && cleanedFull != cleanBase {
return "", fmt.Errorf("pricing path %q escapes middleware-data-dir", relPath)
}
return cleanedFull, nil
}
func parsePricingBytes(data []byte) (*Table, error) {
dec := yaml.NewDecoder(bytes.NewReader(data))
dec.KnownFields(true)
var raw pricingFile
if err := dec.Decode(&raw); err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("decode pricing yaml: %w", err)
}
out := make(map[string]map[string]Entry, len(raw))
for provider, models := range raw {
inner := make(map[string]Entry, len(models))
for model, entry := range models {
for field, v := range map[string]float64{
"input_per_1k": entry.InputPer1K,
"output_per_1k": entry.OutputPer1K,
"cached_input_per_1k": entry.CachedInputPer1K,
"cache_read_per_1k": entry.CacheReadPer1K,
"cache_creation_per_1k": entry.CacheCreationPer1K,
} {
if v < 0 || math.IsNaN(v) || math.IsInf(v, 0) {
return nil, fmt.Errorf("pricing %s/%s: %s must be a finite, non-negative rate, got %v", provider, model, field, v)
}
}
inner[model] = Entry{
InputPer1K: entry.InputPer1K,
OutputPer1K: entry.OutputPer1K,
CachedInputPer1K: entry.CachedInputPer1K,
CacheReadPer1K: entry.CacheReadPer1K,
CacheCreationPer1K: entry.CacheCreationPer1K,
}
}
out[provider] = inner
}
return &Table{entries: out}, nil
}

View File

@@ -0,0 +1,20 @@
//go:build !unix
package pricing
import (
"fmt"
"time"
)
// loadPricing is unavailable on non-Unix platforms because O_NOFOLLOW and
// fstat-from-FD are required to honour the spec's symlink-safety rules. The
// proxy is only deployed on Linux today; a Windows port would need an
// equivalent path-as-handle implementation.
func loadPricing(path string) (*Table, time.Time, error) {
return nil, time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path)
}
func statMtime(path string) (time.Time, error) {
return time.Time{}, fmt.Errorf("llmobs pricing loader is not supported on this platform: %s", path)
}

View File

@@ -1,13 +1,47 @@
//go:build unix
package pricing
import (
"math"
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func copyFixture(t *testing.T, src, dst string) {
t.Helper()
data, err := os.ReadFile(src)
require.NoError(t, err, "read source fixture")
require.NoError(t, os.WriteFile(dst, data, 0o600), "write target fixture")
}
func TestNewLoader_HappyPath(t *testing.T) {
base := t.TempDir()
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml"))
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err, "NewLoader must succeed with a valid fixture")
table := l.Get()
require.NotNil(t, table, "table populated after load")
cost, ok := table.Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0)
require.True(t, ok, "known provider/model resolves")
assert.InDelta(t, 0.00075, cost, 1e-9, "cost = 0.00015 + 0.0006 per 1k tokens")
cost, ok = table.Cost("openai", "gpt-4o", 2000, 1000, 0, 0)
require.True(t, ok, "second known model resolves")
assert.InDelta(t, 0.015, cost, 1e-9, "cost for gpt-4o: 2*0.0025 + 1*0.01")
cost, ok = table.Cost("anthropic", "claude-sonnet-4-5", 1000, 1000, 0, 0)
require.True(t, ok, "anthropic model resolves")
assert.InDelta(t, 0.018, cost, 1e-9, "cost for claude-sonnet-4-5: 0.003 + 0.015")
}
// TestCost_OpenAICachedSubsetDiscount proves OpenAI's cached input
// tokens are billed at the configured cached_input_per_1k rate while
// the non-cached remainder of input_tokens is billed at the regular
@@ -31,9 +65,11 @@ func TestCost_OpenAICachedSubsetDiscount(t *testing.T) {
"cached subset must bill at the discount rate; non-cached remainder at regular rate")
}
// TestCost_OpenAICachedFallsBackToInputRate covers the fallback
// contract: when CachedInputPer1K is unset (zero), cached tokens bill
// at the regular input rate.
// TestCost_OpenAICachedFallsBackToInputRate covers the operator
// opt-in contract: when CachedInputPer1K is unset (zero), cached
// tokens bill at the regular input rate. This matches today's
// behaviour (cached counts weren't extracted at all so they
// implicitly billed at the input rate via prompt_tokens).
func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}},
@@ -42,7 +78,7 @@ func TestCost_OpenAICachedFallsBackToInputRate(t *testing.T) {
require.True(t, ok)
want := 0.0025 + (500.0/1000.0)*0.01
assert.InDelta(t, want, cost, 1e-12,
"absent cached_input_per_1k rate must fall back to input_per_1k")
"absent cached_input_per_1k rate must fall back to input_per_1k — same as pre-feature behaviour")
}
// TestCost_OpenAIClampsCachedToInputCount is the defensive guard
@@ -64,7 +100,10 @@ func TestCost_OpenAIClampsCachedToInputCount(t *testing.T) {
// TestCost_AnthropicCacheReadAndCreationAreAdditive proves the
// Anthropic shape: cache_read and cache_creation tokens are
// ADDITIVE to input_tokens (not subset), each billed at its own
// configured rate.
// configured rate. The two rates pull in opposite directions —
// cache_read is the cheaper read-from-cache rate (≈0.1× input),
// cache_creation is the more expensive write-to-cache rate
// (≈1.25× input).
func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"anthropic": {"claude-sonnet": {
@@ -86,9 +125,11 @@ func TestCost_AnthropicCacheReadAndCreationAreAdditive(t *testing.T) {
"each Anthropic input bucket must bill at its own configured rate")
}
// TestCost_AnthropicCacheRatesFallBackToInput covers the no-rate
// TestCost_AnthropicCacheRatesFallBackToInput covers the no-opt-in
// path: when neither CacheReadPer1K nor CacheCreationPer1K is set,
// cache tokens bill at the regular input rate.
// cache tokens bill at the regular input rate. This is more
// accurate than today's behaviour (cache tokens ignored entirely)
// without requiring operators to opt in via YAML.
func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) {
tbl := &Table{entries: map[string]map[string]Entry{
"anthropic": {"claude-sonnet": {InputPer1K: 0.003, OutputPer1K: 0.015}},
@@ -98,39 +139,259 @@ func TestCost_AnthropicCacheRatesFallBackToInput(t *testing.T) {
// Without overrides: every input bucket at input_per_1k.
want := ((256.0+768.0+512.0)/1000.0)*0.003 + (200.0/1000.0)*0.015
assert.InDelta(t, want, cost, 1e-12,
"absent cache rates must fall back to input_per_1k")
"absent cache rates must fall back to input_per_1k — Anthropic cache tokens were ignored before this change, billing at input rate is more accurate as a default")
}
// TestEntryCosts_SurfaceSelectsFormula pins that the formula branches on
// the SURFACE, not on which table the entry came from: the same entry
// bills a subset carve-out on "openai", additive buckets on
// "anthropic"/"bedrock", and ignores cache counts everywhere else. This
// is what keeps per-provider-record entries (looked up by record id)
// mathematically identical to defaults-table entries.
func TestEntryCosts_SurfaceSelectsFormula(t *testing.T) {
e := Entry{InputPer1K: 0.002, OutputPer1K: 0.01, CachedInputPer1K: 0.001, CacheReadPer1K: 0.0002, CacheCreationPer1K: 0.0025}
func TestNewLoader_UnknownModel(t *testing.T) {
base := t.TempDir()
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml"))
openai := EntryCosts(e, "openai", 1000, 0, 400, 300)
assert.InDelta(t, (600.0/1000.0)*0.002+(400.0/1000.0)*0.001, openai.TotalUSD, 1e-12,
"openai: cached is a subset, cacheCreation ignored")
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err)
anthropic := EntryCosts(e, "anthropic", 1000, 0, 400, 300)
assert.InDelta(t, 0.002+(400.0/1000.0)*0.0002+(300.0/1000.0)*0.0025, anthropic.TotalUSD, 1e-12,
"anthropic: cache buckets are additive")
_, ok := l.Get().Cost("openai", "fantasy-model", 10, 10, 0, 0)
assert.False(t, ok, "unknown model returns ok=false")
bedrock := EntryCosts(e, "bedrock", 1000, 0, 400, 300)
assert.InDelta(t, anthropic.TotalUSD, bedrock.TotalUSD, 1e-12, "bedrock shares the anthropic formula")
other := EntryCosts(e, "gemini", 1000, 0, 400, 300)
assert.InDelta(t, 0.002, other.TotalUSD, 1e-12, "unknown surface: cache counts ignored")
_, ok = l.Get().Cost("cohere", "anything", 10, 10, 0, 0)
assert.False(t, ok, "unknown provider returns ok=false")
}
// TestEntryCosts_ClampsNegativeTokens: malformed upstream counts must
// never produce a negative cost.
func TestEntryCosts_ClampsNegativeTokens(t *testing.T) {
e := Entry{InputPer1K: 0.002, OutputPer1K: 0.01}
c := EntryCosts(e, "openai", -50, -10, -5, -3)
assert.Zero(t, c.TotalUSD, "all-negative counts clamp to zero cost")
func TestNewLoader_InvalidYAMLRejected(t *testing.T) {
base := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(base, "pricing.yaml"), []byte("\t- this is not: valid: yaml: :["), 0o600))
_, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.Error(t, err, "invalid YAML must surface as construction error")
}
func TestLoader_ReloadKeepsPreviousOnParseError(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "pricing.yaml")
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target)
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err)
require.NotNil(t, l.Get(), "initial table populated")
// Overwrite with content that violates the strict schema (extra field)
// plus a bumped mtime to trigger reload.
require.NoError(t, os.WriteFile(target, []byte("openai:\n gpt-4o:\n input_per_1k: 1.0\n output_per_1k: 2.0\n bogus_field: nope\n"), 0o600))
future := time.Now().Add(time.Hour)
require.NoError(t, os.Chtimes(target, future, future))
err = l.reload()
require.Error(t, err, "parse error surfaced by reload()")
cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0)
require.True(t, ok, "previous table still available after parse failure")
assert.InDelta(t, 0.00075, cost, 1e-9, "previous cost preserved")
}
func TestLoader_ReloadNoChangeIsNoOp(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "pricing.yaml")
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target)
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err)
ptrBefore := l.Get()
require.NoError(t, l.reload(), "no-change reload must not error")
ptrAfter := l.Get()
assert.Same(t, ptrBefore, ptrAfter, "table pointer unchanged when mtime unchanged")
}
func TestLoader_ReloadDetectsChange(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "pricing.yaml")
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target)
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err)
updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n")
require.NoError(t, os.WriteFile(target, updated, 0o600))
future := time.Now().Add(time.Hour)
require.NoError(t, os.Chtimes(target, future, future))
require.NoError(t, l.reload(), "reload must succeed on valid new content")
cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0)
require.True(t, ok, "updated model still present")
assert.InDelta(t, 3.0, cost, 0.0001, "new prices are applied: 1 + 2 per 1k")
}
// TestLoader_ReloadGoroutinePicksUpChanges proves the background goroutine
// started via Reload actually swaps the pricing table when the file changes
// on disk. Without that goroutine running, pricing edits would never reach
// requests until a proxy restart.
func TestLoader_ReloadGoroutinePicksUpChanges(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "pricing.yaml")
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target)
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err)
l.SetReloadInterval(20 * time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
l.Reload(ctx)
close(done)
}()
// Before any rewrite, the loader holds the fixture's prices.
costBefore, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0)
require.True(t, ok, "fixture model must resolve initially")
assert.InDelta(t, 0.00075, costBefore, 1e-9, "fixture prices apply before rewrite")
updated := []byte("openai:\n gpt-4o-mini:\n input_per_1k: 1.00\n output_per_1k: 2.00\n")
require.NoError(t, os.WriteFile(target, updated, 0o600))
future := time.Now().Add(time.Hour)
require.NoError(t, os.Chtimes(target, future, future))
deadline := time.Now().Add(2 * time.Second)
for {
cost, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0)
if ok && cost > 2.5 {
break
}
if time.Now().After(deadline) {
t.Fatalf("background reloader did not pick up rewrite within deadline")
}
time.Sleep(10 * time.Millisecond)
}
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Reload loop did not exit after cancel")
}
}
func TestLoader_ReloadBackgroundLoopCancellation(t *testing.T) {
base := t.TempDir()
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml"))
l, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
l.Reload(ctx)
close(done)
}()
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatal("Reload loop did not exit on context cancel")
}
}
func TestNewLoader_PathValidation(t *testing.T) {
base := t.TempDir()
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml"))
cases := []struct {
name string
relPath string
}{
{"traversal", "../../etc/passwd"},
{"absolute", "/etc/passwd"},
{"slash in basename", "sub/pricing.yaml"},
{"control chars", "pricing\x00.yaml"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewLoader(base, tc.relPath, "llm_observability", nil)
require.Error(t, err, "NewLoader must reject %q", tc.relPath)
})
}
// Empty relPath is no longer a validation error: the loader treats it
// as "no override file, defaults only" so cost metadata is still
// emitted for the embedded models out of the box.
t.Run("empty falls back to defaults", func(t *testing.T) {
l, err := NewLoader(base, "", "llm_observability", nil)
require.NoError(t, err, "empty relPath should yield a defaults-only loader")
require.NotNil(t, l, "loader must be returned")
require.False(t, l.WatchesFile(), "no file watching when no override is given")
_, ok := l.Get().Cost("openai", "gpt-4o-mini", 1000, 1000, 0, 0)
assert.True(t, ok, "embedded defaults should still resolve gpt-4o-mini")
})
}
// TestNewLoader_PathValidation_Extended covers the remaining attack shapes
// called out in C2: dot references, embedded traversal segments, and a
// newline in the basename. The basename regex must reject each one even
// though filepath.Clean would otherwise collapse them.
func TestNewLoader_PathValidation_Extended(t *testing.T) {
base := t.TempDir()
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing.yaml"))
cases := []struct {
name string
relPath string
}{
{"dot", "."},
{"dotdot", ".."},
{"relative traversal", "../pricing.yaml"},
{"embedded slash", "pri/cing.yaml"},
{"newline", "pricing\n.yaml"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := NewLoader(base, tc.relPath, "llm_observability", nil)
require.Error(t, err, "NewLoader must reject %q", tc.relPath)
})
}
}
// TestNewLoader_ValidBasenameLoads proves the allowlist is exclusive: a
// basename containing only safe characters under baseDir loads. Without this
// a regression that over-tightened the regex would silently break valid
// deployments.
func TestNewLoader_ValidBasenameLoads(t *testing.T) {
base := t.TempDir()
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), filepath.Join(base, "pricing-v2_prod.yaml"))
l, err := NewLoader(base, "pricing-v2_prod.yaml", "llm_observability", nil)
require.NoError(t, err, "basename with _, -, . must load")
require.NotNil(t, l.Get(), "table populated")
}
// TestNewLoader_SymlinkOutsideBaseDirRejected constructs a symlink under
// baseDir that points to a file outside it. O_NOFOLLOW must refuse to open
// the symlink even though the symlink path itself is a valid basename under
// baseDir.
func TestNewLoader_SymlinkOutsideBaseDirRejected(t *testing.T) {
outside := t.TempDir()
target := filepath.Join(outside, "evil.yaml")
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), target)
base := t.TempDir()
link := filepath.Join(base, "pricing.yaml")
require.NoError(t, os.Symlink(target, link), "symlink setup")
_, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.Error(t, err, "O_NOFOLLOW must reject symlink even when it points outside baseDir")
}
func TestNewLoader_SymlinkRejected(t *testing.T) {
base := t.TempDir()
concrete := filepath.Join(base, "real.yaml")
copyFixture(t, filepath.Join("..", "fixtures", "pricing.yaml"), concrete)
link := filepath.Join(base, "pricing.yaml")
require.NoError(t, os.Symlink(concrete, link), "symlink setup")
_, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.Error(t, err, "O_NOFOLLOW must reject symlinked targets")
}
func TestTableCost_NilSafe(t *testing.T) {
@@ -141,37 +402,31 @@ func TestTableCost_NilSafe(t *testing.T) {
assert.False(t, t1.Has("x", "y"), "nil table has nothing")
}
func TestNewTable_ValidatesRates(t *testing.T) {
good := map[string]map[string]EntryJSON{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125}},
}
tbl, err := NewTable(good)
require.NoError(t, err)
cost, ok := tbl.Cost("openai", "gpt-4o", 1000, 1000, 0, 0)
require.True(t, ok, "entry survives the wire conversion")
assert.InDelta(t, 0.0125, cost, 1e-9)
_, ok = tbl.Cost("openai", "unknown-model", 1, 1, 0, 0)
assert.False(t, ok, "unknown model misses")
for name, bad := range map[string]EntryJSON{
"negative input": {InputPer1K: -1, OutputPer1K: 0.01},
"NaN output": {InputPer1K: 0.01, OutputPer1K: math.NaN()},
"Inf cache read": {InputPer1K: 0.01, OutputPer1K: 0.01, CacheReadPer1K: math.Inf(1)},
"negative cached": {InputPer1K: 0.01, OutputPer1K: 0.01, CachedInputPer1K: -0.001},
} {
_, err := NewTable(map[string]map[string]EntryJSON{"openai": {"m": bad}})
assert.Error(t, err, "case %q must be rejected so a corrupt config fails the chain build instead of mispricing", name)
}
func TestLoaderGet_NilSafe(t *testing.T) {
var l *Loader
assert.Nil(t, l.Get(), "nil loader returns nil table")
}
func TestNewTable_NilAndEmpty(t *testing.T) {
tbl, err := NewTable(nil)
require.NoError(t, err, "nil map builds an empty (never-matching) table")
_, ok := tbl.Cost("openai", "gpt-4o", 1, 1, 0, 0)
assert.False(t, ok, "empty table prices nothing")
// TestNewLoader_RejectsOversizedFile_FixesM4 proves the loader bounds reads
// at maxPricingBytes so a hostile file cannot exhaust process memory.
func TestNewLoader_RejectsOversizedFile_FixesM4(t *testing.T) {
base := t.TempDir()
target := filepath.Join(base, "pricing.yaml")
entries, err := NewEntries(nil)
require.NoError(t, err)
assert.Empty(t, entries, "nil in, empty (never-matching) map out for the per-record map")
// Build a YAML payload larger than the cap. We pad with valid YAML
// comments so a partial read would still fail the size check rather
// than the parser.
header := "openai:\n"
bigComment := make([]byte, maxPricingBytes+1024)
for i := range bigComment {
bigComment[i] = ' '
}
bigComment[0] = '#'
bigComment[len(bigComment)-1] = '\n'
payload := append([]byte(header), bigComment...)
require.NoError(t, os.WriteFile(target, payload, 0o600))
_, err := NewLoader(base, "pricing.yaml", "llm_observability", nil)
require.Error(t, err, "oversized pricing file must be rejected")
assert.Contains(t, err.Error(), "exceeds", "rejection must reference the byte cap")
}

View File

@@ -0,0 +1,68 @@
//go:build unix
package pricing
import (
"fmt"
"io"
"os"
"syscall"
"time"
log "github.com/sirupsen/logrus"
)
// maxPricingBytes caps the size of the pricing YAML on read so a hostile or
// runaway file cannot exhaust process memory during reload. 1 MiB is several
// orders of magnitude larger than any reasonable pricing table.
const maxPricingBytes int64 = 1 << 20
// loadPricing opens the file with O_NOFOLLOW, fstats the open descriptor,
// and parses from that same descriptor. Never re-opens by path so a
// mid-read rename or symlink swap cannot substitute content. Bytes are
// capped at maxPricingBytes so the loader cannot be coerced into reading an
// unbounded file.
func loadPricing(path string) (*Table, time.Time, error) {
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW, 0)
if err != nil {
return nil, time.Time{}, fmt.Errorf("open %s: %w", path, err)
}
defer func() {
if cerr := f.Close(); cerr != nil {
log.Debugf("close pricing file %s: %v", path, cerr)
}
}()
info, err := f.Stat()
if err != nil {
return nil, time.Time{}, fmt.Errorf("fstat %s: %w", path, err)
}
if !info.Mode().IsRegular() {
return nil, time.Time{}, fmt.Errorf("pricing file %s is not a regular file", path)
}
data, err := io.ReadAll(io.LimitReader(f, maxPricingBytes+1))
if err != nil {
return nil, time.Time{}, fmt.Errorf("read %s: %w", path, err)
}
if int64(len(data)) > maxPricingBytes {
return nil, time.Time{}, fmt.Errorf("pricing file %s exceeds %d bytes", path, maxPricingBytes)
}
table, err := parsePricingBytes(data)
if err != nil {
return nil, time.Time{}, err
}
return table, info.ModTime(), nil
}
// statMtime returns the mtime of the file at path. It uses lstat semantics
// via os.Lstat so a symlink swap is detected even though O_NOFOLLOW will
// later reject the open.
func statMtime(path string) (time.Time, error) {
info, err := os.Lstat(path)
if err != nil {
return time.Time{}, fmt.Errorf("lstat %s: %w", path, err)
}
return info.ModTime(), nil
}

View File

@@ -36,13 +36,15 @@ var defaultRegistry = middleware.NewRegistry()
// FactoryContext is the per-process bag that concrete factories may
// consult during construction. It carries the proxy-lifetime context,
// the OTel meter, and the proxy logger.
// the data directory used for static config files (pricing tables,
// allowlists), the OTel meter, and the proxy logger.
//
// Configure must be called once at boot before any chain build calls
// Resolve. Calling it twice overwrites the prior value; tests may rely
// on this to reset state.
type FactoryContext struct {
Context context.Context
DataDir string
Meter metric.Meter
Logger *log.Logger
MgmtClient MgmtClient
@@ -56,11 +58,12 @@ var (
// Configure stores the per-process FactoryContext. Concrete factories
// reach for it via Context(). mgmt may be nil on tests / standalone
// builds with no management server; consumers must guard.
func Configure(ctx context.Context, meter metric.Meter, logger *log.Logger, mgmt MgmtClient) {
func Configure(ctx context.Context, dataDir string, meter metric.Meter, logger *log.Logger, mgmt MgmtClient) {
ctxMu.Lock()
defer ctxMu.Unlock()
ctxStore = FactoryContext{
Context: ctx,
DataDir: dataDir,
Meter: meter,
Logger: logger,
MgmtClient: mgmt,

View File

@@ -12,7 +12,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
mgmtpricing "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/pricing"
"github.com/netbirdio/netbird/proxy/internal/middleware"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/cost_meter"
@@ -20,20 +19,17 @@ import (
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_response_parser"
)
// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the REAL default pricing
// table management ships (mgmtpricing.DefaultTable, catalog-derived) and asserts exact USD amounts hardcoded from
// the vendors' published prices, including the cache split. This is the cross-stack pricing contract test: the
// management-side Entry JSON must decode into the proxy-side table and produce these exact costs.
// Drives the real pipeline (llm_request_parser → llm_response_parser → cost_meter) on the embedded default pricing
// table and asserts exact USD amounts hardcoded from the vendors' published prices, including the cache split.
func TestCostCalculation_ProviderMatrix(t *testing.T) {
builtin.Configure(context.Background(), nil, nil, nil)
// Empty data dir → embedded defaults, like a proxy with no pricing override.
builtin.Configure(context.Background(), t.TempDir(), nil, nil, nil)
reqMW, err := llm_request_parser.Factory{}.New(nil)
require.NoError(t, err, "build llm_request_parser")
respMW, err := llm_response_parser.Factory{}.New(nil)
require.NoError(t, err, "build llm_response_parser")
costCfgJSON, err := json.Marshal(map[string]any{"pricing": map[string]any{"defaults": mgmtpricing.DefaultTable()}})
require.NoError(t, err, "marshal management default table into cost_meter config")
costMW, err := cost_meter.Factory{}.New(costCfgJSON)
costMW, err := cost_meter.Factory{}.New(nil)
require.NoError(t, err, "build cost_meter")
t.Cleanup(func() { _ = costMW.Close() })

View File

@@ -2,6 +2,7 @@ package cost_meter
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -10,27 +11,16 @@ import (
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
)
// Config is the on-wire configuration for the middleware, synthesized by
// management (buildCostMeterConfigJSON). The proxy has no embedded price
// list: this payload is the only pricing source, and updates arrive as
// ordinary mapping pushes that rebuild the chain (and with it this
// middleware instance) — no per-request fetches, no reload loops.
type Config struct {
Pricing *PricingConfig `json:"pricing"`
}
// defaultPricingFilename is the basename probed inside the proxy data
// directory when no override is configured.
const defaultPricingFilename = "pricing.yaml"
// PricingConfig carries the full pricing table:
// - Defaults: parser surface ("openai"/"anthropic"/"bedrock") ->
// normalized model id -> rates, matched against llm.provider +
// llm.model.
// - Providers: provider record id -> normalized model id -> rates,
// matched against the llm.resolved_provider_id metadata llm_router
// stamps. Entries arrive fully materialized (management folds default
// cache rates in at synth time), so lookup order is simply
// per-record first, defaults second.
type PricingConfig struct {
Defaults map[string]map[string]pricing.EntryJSON `json:"defaults"`
Providers map[string]map[string]pricing.EntryJSON `json:"providers"`
// Config is the on-wire configuration for the middleware.
type Config struct {
// PricingPath optionally overrides the basename of the pricing
// file probed inside the proxy data directory. When empty the
// loader falls back to "pricing.yaml".
PricingPath string `json:"pricing_path"`
}
// Factory builds cost_meter instances from raw config bytes.
@@ -39,45 +29,45 @@ type Factory struct{}
// ID returns the registry identifier.
func (Factory) ID() string { return ID }
// New constructs a middleware instance. Empty, null, and {} configs are
// accepted for backward compatibility with a management server that
// predates config-delivered pricing — the instance then skips every cost
// computation (unknown_model) and a warning is logged once at build time.
// Non-empty rawConfig that fails to unmarshal, or a table carrying a
// non-finite / negative rate, is rejected so misconfigurations surface at
// chain build time.
// New constructs a middleware instance. Empty, null, and {} configs
// are accepted; non-empty rawConfig that fails to unmarshal is
// rejected so misconfigurations surface at chain build time. The
// pricing loader is built once per instance and reused across
// invocations.
func (Factory) New(rawConfig []byte) (middleware.Middleware, error) {
cfg, err := decodeConfig(rawConfig)
if err != nil {
return nil, err
}
if cfg.Pricing == nil {
if logger := builtin.Context().Logger; logger != nil {
logger.Warnf("cost_meter: no pricing table in middleware config; management predates config-delivered pricing — every request will record cost.skipped=unknown_model ($0)")
}
return newMiddleware(mustEmptyTable(), nil), nil
fctx := builtin.Context()
pricingPath := cfg.PricingPath
if pricingPath == "" {
pricingPath = defaultPricingFilename
}
defaults, err := pricing.NewTable(cfg.Pricing.Defaults)
loader, err := pricing.NewLoader(fctx.DataDir, pricingPath, ID, nil)
if err != nil {
return nil, fmt.Errorf("cost_meter pricing defaults: %w", err)
return nil, fmt.Errorf("init pricing loader: %w", err)
}
perRecord, err := pricing.NewEntries(cfg.Pricing.Providers)
if err != nil {
return nil, fmt.Errorf("cost_meter per-provider pricing: %w", err)
}
return newMiddleware(defaults, perRecord), nil
cancel := startReloader(fctx.Context, loader)
return newMiddleware(loader, cancel), nil
}
// mustEmptyTable returns a valid empty table. NewTable on a nil map cannot
// fail; the panic guard documents that invariant.
func mustEmptyTable() *pricing.Table {
t, err := pricing.NewTable(nil)
if err != nil {
panic(fmt.Sprintf("cost_meter: empty pricing table must build: %v", err))
// startReloader binds the loader's mtime-poll goroutine to a context
// derived from the proxy-lifetime context and returns its cancel func so
// the owning middleware can stop the goroutine on teardown. Returns nil
// when there's nothing to watch (nil context or defaults-only loader), in
// which case the middleware's Close is a no-op.
func startReloader(ctx context.Context, loader *pricing.Loader) context.CancelFunc {
if ctx == nil || !loader.WatchesFile() {
return nil
}
return t
cctx, cancel := context.WithCancel(ctx)
go loader.Reload(cctx)
return cancel
}
// decodeConfig accepts empty, null, and {} configs, returning a

View File

@@ -1,9 +1,7 @@
// Package cost_meter implements the SlotOnResponse middleware that
// converts token-usage metadata emitted by llm_response_parser into a
// per-request USD cost estimate. Pricing arrives from management inside
// the middleware config: a per-provider-record table (the operator's
// stored prices, matched via llm.resolved_provider_id) consulted first,
// then the surface-keyed defaults table.
// per-request USD cost estimate. The middleware uses the shared pricing
// loader so operator pricing overrides apply to the chain.
package cost_meter
import (
@@ -19,9 +17,7 @@ import (
const ID = "cost_meter"
// Version is the implementation version emitted via the spec merge.
// 1.1.0: pricing is config-delivered (defaults + per-provider-record
// entries) instead of proxy-embedded.
const Version = "1.1.0"
const Version = "1.0.0"
// Skip reasons emitted under KeyCostSkipped. The set is closed; the
// dashboard surfaces these verbatim.
@@ -46,21 +42,19 @@ var metadataKeys = []string{
}
// Middleware computes a per-response cost estimate from the token
// counts emitted upstream by llm_response_parser. Both tables are
// immutable — a pricing change arrives as a mapping push that rebuilds
// the chain with a fresh instance.
// counts emitted upstream by llm_response_parser.
type Middleware struct {
// defaults is the surface-keyed table (llm.provider x llm.model).
defaults *pricing.Table
// perRecord is keyed by provider record id (llm.resolved_provider_id)
// then normalized model id; entries arrive fully materialized from
// management. Consulted before defaults. May be nil.
perRecord map[string]map[string]pricing.Entry
loader *pricing.Loader
// cancel stops this instance's pricing-reload goroutine. Non-nil only
// when the loader watches an override file; Close calls it so a chain
// rebuild doesn't leak a poll goroutine per retired instance.
cancel context.CancelFunc
}
// newMiddleware constructs a Middleware over the given pricing tables.
func newMiddleware(defaults *pricing.Table, perRecord map[string]map[string]pricing.Entry) *Middleware {
return &Middleware{defaults: defaults, perRecord: perRecord}
// newMiddleware constructs a Middleware bound to the given pricing loader.
// cancel may be nil (defaults-only loader with no reloader to stop).
func newMiddleware(loader *pricing.Loader, cancel context.CancelFunc) *Middleware {
return &Middleware{loader: loader, cancel: cancel}
}
// ID returns the registry identifier.
@@ -85,9 +79,16 @@ func (m *Middleware) MetadataKeys() []string {
// response.
func (m *Middleware) MutationsSupported() bool { return false }
// Close releases resources owned by the middleware. Stateless — the
// pricing tables are plain maps owned by this instance.
func (m *Middleware) Close() error { return nil }
// Close stops this instance's pricing-reload goroutine, if any. Called by
// the chain when a rebuild retires the instance, so the mtime-poll loop
// doesn't outlive the chain it belonged to. Safe to call on a nil receiver
// and on an instance with no reloader.
func (m *Middleware) Close() error {
if m != nil && m.cancel != nil {
m.cancel()
}
return nil
}
// Invoke reads provider, model, and token metadata, looks up pricing,
// and emits either KeyCostUSDTotal or KeyCostSkipped. The decision is
@@ -143,7 +144,8 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
return out, nil
}
costs, ok := m.lookupCosts(in.Metadata, provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
table := m.loader.Get()
costs, ok := table.Costs(provider, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
if !ok {
out.Metadata = skip(skipUnknownModel)
return out, nil
@@ -162,26 +164,6 @@ func (m *Middleware) Invoke(_ context.Context, in *middleware.Input) (*middlewar
return out, nil
}
// lookupCosts resolves the price for this request and computes the cost
// split. Resolution order:
//
// 1. Per-provider-record entry: the operator's stored price for the
// provider route that served the request, keyed by the
// llm.resolved_provider_id metadata llm_router stamped on the allow
// path. Absent metadata (e.g. no router in the chain) skips this tier.
// 2. Surface defaults: the catalog-derived table keyed by llm.provider.
//
// The surface always selects the cache formula — a per-record entry for an
// Anthropic route still bills its cache buckets additively.
func (m *Middleware) lookupCosts(md []middleware.KV, surface, model string, inTokens, outTokens, cachedTokens, cacheCreationTokens int64) (pricing.Costs, bool) {
if recordID := lookupKV(md, middleware.KeyLLMResolvedProviderID); recordID != "" {
if entry, ok := m.perRecord[recordID][model]; ok {
return pricing.EntryCosts(entry, surface, inTokens, outTokens, cachedTokens, cacheCreationTokens), true
}
}
return m.defaults.Costs(surface, model, inTokens, outTokens, cachedTokens, cacheCreationTokens)
}
// usd renders a cost as the fixed-precision string every cost.usd_* key
// carries, so the per-bucket values and the aggregates round identically.
//

View File

@@ -3,50 +3,39 @@ package cost_meter
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/llm/pricing"
"github.com/netbirdio/netbird/proxy/internal/middleware"
"github.com/netbirdio/netbird/proxy/internal/middleware/builtin"
)
// fixtureConfig mirrors what management's buildCostMeterConfigJSON ships:
// a surface-keyed defaults table. Rates match the retired YAML fixture so
// every cost assertion below is byte-identical to the pre-feature values.
func fixtureConfig(t *testing.T) []byte {
t.Helper()
raw, err := json.Marshal(Config{Pricing: &PricingConfig{
Defaults: map[string]map[string]pricing.EntryJSON{
"openai": {
"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01},
"gpt-4o-mini": {InputPer1K: 0.00015, OutputPer1K: 0.0006},
},
"anthropic": {
"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015},
},
},
}})
require.NoError(t, err)
return raw
}
const fixturePricing = `openai:
gpt-4o:
input_per_1k: 0.0025
output_per_1k: 0.01
gpt-4o-mini:
input_per_1k: 0.00015
output_per_1k: 0.0006
anthropic:
claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
`
// fixtureConfigWithCache adds the cache-rate fields.
func fixtureConfigWithCache(t *testing.T) []byte {
// configureBuiltin points the package-level FactoryContext at a tmp
// directory containing the test pricing fixture. Returns the path so
// callers can override files later if needed.
func configureBuiltin(t *testing.T) string {
t.Helper()
raw, err := json.Marshal(Config{Pricing: &PricingConfig{
Defaults: map[string]map[string]pricing.EntryJSON{
"openai": {
"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01, CachedInputPer1K: 0.00125},
},
"anthropic": {
"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015, CacheReadPer1K: 0.0003, CacheCreationPer1K: 0.00375},
},
},
}})
require.NoError(t, err)
return raw
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricing), 0o600), "write pricing fixture")
builtin.Configure(context.Background(), dir, nil, nil, nil)
return dir
}
func metaValue(t *testing.T, kvs []middleware.KV, key string) (string, bool) {
@@ -67,7 +56,8 @@ func buildMiddleware(t *testing.T, raw []byte) middleware.Middleware {
}
func TestMiddleware_StaticSurface(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
assert.Equal(t, ID, mw.ID(), "ID must match the registered constant")
assert.Equal(t, Version, mw.Version(), "Version must match the constant")
@@ -89,10 +79,8 @@ func TestMiddleware_StaticSurface(t *testing.T) {
assert.Equal(t, expected, keys, "metadata key allowlist must match the spec")
}
// TestFactory_AcceptsEmptyAndJSONConfig: empty/null/{} configs are what an
// old management (pre config-delivered pricing) sends — they must build a
// working (all-skip) instance, never fail the chain.
func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) {
configureBuiltin(t)
cases := [][]byte{nil, {}, []byte("null"), []byte("{}"), []byte(" ")}
for _, raw := range cases {
mw, err := Factory{}.New(raw)
@@ -102,57 +90,15 @@ func TestFactory_AcceptsEmptyAndJSONConfig(t *testing.T) {
}
func TestFactory_RejectsMalformedConfig(t *testing.T) {
configureBuiltin(t)
mw, err := Factory{}.New([]byte("{not json"))
require.Error(t, err, "malformed config must surface at construction")
assert.Nil(t, mw, "no instance is returned on error")
}
// TestFactory_RejectsInvalidRates: a non-finite or negative rate anywhere
// in the table fails the chain build (defense-in-depth behind management's
// API validation) rather than silently mispricing.
func TestFactory_RejectsInvalidRates(t *testing.T) {
raw, err := json.Marshal(Config{Pricing: &PricingConfig{
Defaults: map[string]map[string]pricing.EntryJSON{
"openai": {"gpt-4o": {InputPer1K: -0.0025, OutputPer1K: 0.01}},
},
}})
require.NoError(t, err)
mw, err := Factory{}.New(raw)
require.Error(t, err, "negative rate must fail the build")
assert.Nil(t, mw)
raw, err = json.Marshal(Config{Pricing: &PricingConfig{
Providers: map[string]map[string]pricing.EntryJSON{
"prov-1": {"m": {InputPer1K: 0.01, OutputPer1K: 0.01, CacheReadPer1K: -1}},
},
}})
require.NoError(t, err)
_, err = Factory{}.New(raw)
require.Error(t, err, "per-record tables validate too")
}
// TestFactory_NilPricingSkipsEverything is the version-skew contract: a
// new proxy under an old management ({} config) must build, allow, and
// skip with unknown_model — degraded but never broken.
func TestFactory_NilPricingSkipsEverything(t *testing.T) {
mw := buildMiddleware(t, []byte("{}"))
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
{Key: middleware.KeyLLMProvider, Value: "openai"},
{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
{Key: middleware.KeyLLMInputTokens, Value: "1000"},
{Key: middleware.KeyLLMOutputTokens, Value: "1000"},
},
})
require.NoError(t, err)
assert.Equal(t, middleware.DecisionAllow, out.Decision, "cost_meter always allows")
value, ok := metaValue(t, out.Metadata, middleware.KeyCostSkipped)
require.True(t, ok, "no pricing table means every request skips")
assert.Equal(t, skipUnknownModel, value)
}
func TestFactory_ConfigDefaultsPriceRequests(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
func TestFactory_DefaultPricingPathLoadsFixture(t *testing.T) {
configureBuiltin(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -170,78 +116,34 @@ func TestFactory_ConfigDefaultsPriceRequests(t *testing.T) {
assert.Equal(t, "0.000750000", value, "0.00015 + 0.0006 per 1k tokens, 9-decimal format")
}
// TestInvoke_PerRecordEntryBeatsDefaults: when llm_router resolved a
// provider record whose operator pinned a price for the model, that price
// wins over the surface default.
func TestInvoke_PerRecordEntryBeatsDefaults(t *testing.T) {
raw, err := json.Marshal(Config{Pricing: &PricingConfig{
Defaults: map[string]map[string]pricing.EntryJSON{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}},
},
Providers: map[string]map[string]pricing.EntryJSON{
"prov-azure": {"gpt-4o": {InputPer1K: 0.005, OutputPer1K: 0.02}},
},
}})
require.NoError(t, err)
mw := buildMiddleware(t, raw)
func TestFactory_PricingPathOverride(t *testing.T) {
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "custom.yaml"), []byte(fixturePricing), 0o600), "write custom pricing")
builtin.Configure(context.Background(), dir, nil, nil, nil)
raw, err := json.Marshal(Config{PricingPath: "custom.yaml"})
require.NoError(t, err)
mw := buildMiddleware(t, raw)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
{Key: middleware.KeyLLMProvider, Value: "openai"},
{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-azure"},
{Key: middleware.KeyLLMInputTokens, Value: "1000"},
{Key: middleware.KeyLLMInputTokens, Value: "2000"},
{Key: middleware.KeyLLMOutputTokens, Value: "1000"},
},
})
require.NoError(t, err)
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok)
assert.Equal(t, "0.025000000", value, "operator's per-record price (0.005+0.02) wins over the default (0.0025+0.01)")
require.True(t, ok, "cost.usd_total must be emitted with custom pricing path")
assert.Equal(t, "0.015000000", value, "2*0.0025 + 1*0.01 = 0.015 with 9-decimal format")
}
// TestInvoke_PerRecordMissFallsBackToDefaults: a resolved record with no
// entry for this model (or no entries at all) falls through to the
// surface defaults — gateway providers rely on exactly this.
func TestInvoke_PerRecordMissFallsBackToDefaults(t *testing.T) {
raw, err := json.Marshal(Config{Pricing: &PricingConfig{
Defaults: map[string]map[string]pricing.EntryJSON{
"openai": {"gpt-4o": {InputPer1K: 0.0025, OutputPer1K: 0.01}},
},
Providers: map[string]map[string]pricing.EntryJSON{
"prov-1": {"some-other-model": {InputPer1K: 1, OutputPer1K: 1}},
},
}})
require.NoError(t, err)
mw := buildMiddleware(t, raw)
func TestInvoke_ComputesCostForKnownModel(t *testing.T) {
configureBuiltin(t)
mw := buildMiddleware(t, nil)
for name, recordID := range map[string]string{
"record with other models": "prov-1",
"record with no entries": "prov-gateway",
} {
t.Run(name, func(t *testing.T) {
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
{Key: middleware.KeyLLMProvider, Value: "openai"},
{Key: middleware.KeyLLMModel, Value: "gpt-4o"},
{Key: middleware.KeyLLMResolvedProviderID, Value: recordID},
{Key: middleware.KeyLLMInputTokens, Value: "1000"},
{Key: middleware.KeyLLMOutputTokens, Value: "1000"},
},
})
require.NoError(t, err)
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok, "per-record miss must fall back to the surface default, not skip")
assert.Equal(t, "0.012500000", value, "default rates apply")
})
}
}
// TestInvoke_NoResolvedProviderIDUsesDefaults: metadata without a
// resolved provider id (router denied, or a chain without llm_router)
// prices from the defaults table directly.
func TestInvoke_NoResolvedProviderIDUsesDefaults(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
{Key: middleware.KeyLLMProvider, Value: "anthropic"},
@@ -251,15 +153,17 @@ func TestInvoke_NoResolvedProviderIDUsesDefaults(t *testing.T) {
},
})
require.NoError(t, err)
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok)
require.True(t, ok, "cost.usd_total must be emitted")
assert.Equal(t, "0.018000000", value, "0.003 + 0.015 = 0.018 with 9-decimal format")
_, skipped := metaValue(t, out.Metadata, middleware.KeyCostSkipped)
assert.False(t, skipped, "cost.skipped must not be set when cost is computed")
}
func TestInvoke_MissingProvider(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -275,7 +179,8 @@ func TestInvoke_MissingProvider(t *testing.T) {
}
func TestInvoke_MissingModel(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -291,7 +196,8 @@ func TestInvoke_MissingModel(t *testing.T) {
}
func TestInvoke_MissingTokens(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
cases := []struct {
name string
@@ -334,7 +240,8 @@ func TestInvoke_MissingTokens(t *testing.T) {
}
func TestInvoke_UnparseableTokens(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
cases := []struct {
name string
@@ -364,7 +271,8 @@ func TestInvoke_UnparseableTokens(t *testing.T) {
}
func TestInvoke_ZeroTokens(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -383,7 +291,8 @@ func TestInvoke_ZeroTokens(t *testing.T) {
}
func TestInvoke_UnknownModel(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -400,7 +309,8 @@ func TestInvoke_UnknownModel(t *testing.T) {
}
func TestInvoke_NilInput(t *testing.T) {
mw := buildMiddleware(t, fixtureConfig(t))
configureBuiltin(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), nil)
require.NoError(t, err)
@@ -409,12 +319,36 @@ func TestInvoke_NilInput(t *testing.T) {
assert.Empty(t, out.Metadata, "no metadata must be emitted on nil input")
}
const fixturePricingWithCache = `openai:
gpt-4o:
input_per_1k: 0.0025
output_per_1k: 0.01
cached_input_per_1k: 0.00125
anthropic:
claude-sonnet-4-5:
input_per_1k: 0.003
output_per_1k: 0.015
cache_read_per_1k: 0.0003
cache_creation_per_1k: 0.00375
`
// configureBuiltinWithCacheRates points the package-level
// FactoryContext at a tmp directory containing pricing entries that
// include the cache rate fields.
func configureBuiltinWithCacheRates(t *testing.T) {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "pricing.yaml"), []byte(fixturePricingWithCache), 0o600), "write cache-aware pricing fixture")
builtin.Configure(context.Background(), dir, nil, nil, nil)
}
// TestInvoke_OpenAICachedSubsetDiscount proves the OpenAI shape end
// to end through the middleware: cached_input_tokens is treated as a
// SUBSET of input_tokens and discounted at the configured rate, not
// added on top.
func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) {
mw := buildMiddleware(t, fixtureConfigWithCache(t))
configureBuiltinWithCacheRates(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -456,7 +390,8 @@ func TestInvoke_OpenAICachedSubsetDiscount(t *testing.T) {
// shape: cache_read and cache_creation are additive to input_tokens
// and each carries its own rate.
func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) {
mw := buildMiddleware(t, fixtureConfigWithCache(t))
configureBuiltinWithCacheRates(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -494,37 +429,8 @@ func TestInvoke_AnthropicCacheBucketsAdditive(t *testing.T) {
"output bucket bills 200 tokens at 0.015/1k")
}
// TestInvoke_PerRecordEntryUsesSurfaceFormula: a per-record entry for an
// anthropic-surface request must bill its cache buckets additively — the
// formula follows llm.provider, not which table the entry came from.
func TestInvoke_PerRecordEntryUsesSurfaceFormula(t *testing.T) {
raw, err := json.Marshal(Config{Pricing: &PricingConfig{
Providers: map[string]map[string]pricing.EntryJSON{
"prov-ant": {"claude-sonnet-4-5": {InputPer1K: 0.003, OutputPer1K: 0.015, CacheReadPer1K: 0.0003, CacheCreationPer1K: 0.00375}},
},
}})
require.NoError(t, err)
mw := buildMiddleware(t, raw)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
{Key: middleware.KeyLLMProvider, Value: "anthropic"},
{Key: middleware.KeyLLMModel, Value: "claude-sonnet-4-5"},
{Key: middleware.KeyLLMResolvedProviderID, Value: "prov-ant"},
{Key: middleware.KeyLLMInputTokens, Value: "256"},
{Key: middleware.KeyLLMOutputTokens, Value: "200"},
{Key: middleware.KeyLLMCachedInputTokens, Value: "768"},
{Key: middleware.KeyLLMCacheCreationTokens, Value: "512"},
},
})
require.NoError(t, err)
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok)
assert.Equal(t, "0.005918400", value, "identical math to the defaults-table entry with the same rates")
}
// assertBucket asserts one per-bucket cost key carries the expected
// 9-decimal value.
// 6-decimal value.
func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) {
t.Helper()
got, ok := metaValue(t, md, key)
@@ -533,10 +439,13 @@ func assertBucket(t *testing.T, md []middleware.KV, key, want, msg string) {
}
// TestInvoke_CachedTokensAbsentFallsBackToBaseFormula covers the
// no-cache-metadata path: with no cached keys emitted, the meter must
// produce exactly the input+output cost.
// "operator hasn't opted in" path: with no cached metadata keys
// emitted, the meter must produce exactly the same cost as before
// the feature landed. Critical so operators with the new binary but
// no YAML changes see no behavioural drift on OpenAI requests.
func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) {
mw := buildMiddleware(t, fixtureConfigWithCache(t))
configureBuiltinWithCacheRates(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -551,7 +460,7 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) {
value, ok := metaValue(t, out.Metadata, middleware.KeyCostUSDTotal)
require.True(t, ok)
// 1000 input * 0.0025 + 500 output * 0.01 = 0.0025 + 0.005 = 0.0075
assert.Equal(t, "0.007500000", value, "no cached metadata = plain input+output cost")
assert.Equal(t, "0.007500000", value, "no cached metadata = same cost as before the feature landed")
}
// TestInvoke_UnparseableCachedTokensSkippedSilently proves the
@@ -560,7 +469,8 @@ func TestInvoke_CachedTokensAbsentFallsBackToBaseFormula(t *testing.T) {
// regular formula. Cache buckets are a refinement, never a reason to
// abort cost computation.
func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) {
mw := buildMiddleware(t, fixtureConfigWithCache(t))
configureBuiltinWithCacheRates(t)
mw := buildMiddleware(t, nil)
out, err := mw.Invoke(context.Background(), &middleware.Input{
Metadata: []middleware.KV{
@@ -577,10 +487,22 @@ func TestInvoke_UnparseableCachedTokensSkippedSilently(t *testing.T) {
assert.Equal(t, "0.007500000", value, "same as the no-cached-metadata path")
}
// TestMiddleware_CloseNilSafe confirms Close is a no-op (no panic) even
// for a nil receiver.
// TestMiddleware_CloseCancelsReloader proves Close stops the per-instance
// pricing-reload goroutine: a chain rebuild retires the old instance and
// calls Close, which must invoke the cancel func startReloader handed it so
// the mtime-poll loop doesn't outlive the chain.
func TestMiddleware_CloseCancelsReloader(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
m := newMiddleware(nil, cancel)
require.NoError(t, m.Close(), "Close must not error")
require.Error(t, ctx.Err(), "Close must cancel the reloader context so the poll goroutine exits")
}
// TestMiddleware_CloseNilSafe confirms Close is a no-op (no panic) for an
// instance with no reloader and for a nil receiver.
func TestMiddleware_CloseNilSafe(t *testing.T) {
require.NoError(t, newMiddleware(nil, nil).Close(), "Close must be a no-op")
require.NoError(t, newMiddleware(nil, nil).Close(), "no-reloader Close must be a no-op")
var m *Middleware
require.NoError(t, m.Close(), "nil-receiver Close must be safe")
}

View File

@@ -6,6 +6,23 @@ import (
"github.com/stretchr/testify/require"
)
func TestNormalizeBedrockModel(t *testing.T) {
cases := map[string]string{
"eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
"us.anthropic.claude-opus-4-8-20250101-v1:0": "anthropic.claude-opus-4-8",
"apac.anthropic.claude-haiku-4-5-v1:0": "anthropic.claude-haiku-4-5",
"anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
"meta.llama3-3-70b-instruct-v1:0": "meta.llama3-3-70b-instruct",
"amazon.nova-pro-v1:0": "amazon.nova-pro",
"amazon.nova-2-lite-v1:0": "amazon.nova-2-lite",
// Inference-profile ARN — model id lives in the last path segment.
"arn:aws:bedrock:eu-central-1:123456789012:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0": "anthropic.claude-sonnet-4-5",
}
for in, want := range cases {
require.Equal(t, want, normalizeBedrockModel(in), "normalize %q", in)
}
}
func TestParseBedrockPath(t *testing.T) {
tests := []struct {
path string

View File

@@ -8,6 +8,7 @@ package llm_request_parser
import (
"context"
"net/url"
"regexp"
"strconv"
"strings"
"unicode/utf8"
@@ -252,7 +253,9 @@ func parseVertexPath(reqPath string) (vertexRequest, bool) {
if c := strings.LastIndex(rest, ":"); c >= 0 {
model, action = rest[:c], rest[c+1:]
}
model = llm.NormalizeVertexModel(model)
if at := strings.Index(model, "@"); at >= 0 {
model = model[:at]
}
if model == "" {
return vertexRequest{}, false
}
@@ -340,6 +343,14 @@ func trimBedrockNamespace(reqPath string) string {
return reqPath
}
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
// version/throughput suffix of a Bedrock model id.
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
// parseBedrockPath extracts the model and streaming/converse flags from an AWS
// Bedrock runtime model endpoint:
//
@@ -364,7 +375,7 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) {
if decoded, err := url.PathUnescape(rawModel); err == nil {
rawModel = decoded
}
model := llm.NormalizeBedrockModel(rawModel)
model := normalizeBedrockModel(rawModel)
if model == "" {
return bedrockRequest{}, false
}
@@ -378,6 +389,30 @@ func parseBedrockPath(reqPath string) (bedrockRequest, bool) {
}
}
// normalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
// prefix, and the version/throughput suffix from a Bedrock model id so it
// matches the catalog/pricing key, e.g.
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
// and "arn:aws:bedrock:eu-central-1:123:inference-profile/eu.anthropic.claude-sonnet-4-5-20250929-v1:0"
// -> "anthropic.claude-sonnet-4-5".
func normalizeBedrockModel(modelID string) string {
m := modelID
// A full ARN (inference-profile / provisioned-throughput / foundation-model)
// carries the model id in its last path segment.
if strings.HasPrefix(m, "arn:") {
if i := strings.LastIndex(m, "/"); i >= 0 {
m = m[i+1:]
}
}
for _, p := range bedrockRegionPrefixes {
if strings.HasPrefix(m, p) {
m = m[len(p):]
break
}
}
return bedrockVersionSuffix.ReplaceAllString(m, "")
}
// invokeBedrock emits the model/provider/session/prompt for an AWS Bedrock
// request. Bedrock is metered under the dedicated "bedrock" parser, which reads
// both the InvokeModel and Converse response shapes.

View File

@@ -18,10 +18,10 @@ import (
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
mgmtgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/internals/modules/agentnetwork"
agentNetworkTypes "github.com/netbirdio/netbird/management/internals/modules/agentnetwork/types"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/proxy/internal/middleware"
"github.com/netbirdio/netbird/proxy/internal/middleware/bodytap"
@@ -134,18 +134,14 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
RedactPii: true,
}))
require.NoError(t, st.SaveAgentNetworkProvider(ctx, &agentNetworkTypes.Provider{
ID: providerID,
AccountID: testAccountID,
ProviderID: "openai_api",
Name: "openai-fullchain-test",
UpstreamURL: upstream.URL, // router rewrites to this
APIKey: "sk-test",
Enabled: true,
// Operator-pinned prices deliberately differ from the catalog's
// gpt-5.4 rates (0.0025/0.015) so the cost assertion below proves
// the per-provider-record price — not the default table — billed
// this request: stored price → synth → wire → cost_meter.
Models: []agentNetworkTypes.ProviderModel{{ID: "gpt-5.4", InputPer1k: 0.004, OutputPer1k: 0.02}},
ID: providerID,
AccountID: testAccountID,
ProviderID: "openai_api",
Name: "openai-fullchain-test",
UpstreamURL: upstream.URL, // router rewrites to this
APIKey: "sk-test",
Enabled: true,
Models: []agentNetworkTypes.ProviderModel{{ID: "gpt-5.4"}},
SessionPrivateKey: "priv",
SessionPublicKey: "pub",
}))
@@ -180,7 +176,7 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
// ---- 5. Wire the middleware framework — same registry the proxy uses
// in production, configured with our bufconn-backed management client.
mwbuiltin.Configure(ctx, nil, testLogger, mgmtClient)
mwbuiltin.Configure(ctx, t.TempDir(), nil, testLogger, mgmtClient)
registry := mwbuiltin.DefaultRegistry()
mwMetrics, err := middleware.NewMetrics(nil)
require.NoError(t, err)
@@ -287,23 +283,13 @@ func TestReverseProxy_AgentNetworkRequest_FullChain(t *testing.T) {
if r.DimensionKind == agentNetworkTypes.DimensionGroup &&
r.DimensionID == adminGroupID &&
r.WindowSeconds == 60 &&
r.TokensInput+r.TokensOutput > 0 &&
r.CostUSD > 0 {
r.TokensInput+r.TokensOutput > 0 {
return true
}
}
return false
}, 5*time.Second, 50*time.Millisecond,
"Admins group consumption row must increment via the response leg WITH a non-zero cost — a zero cost means the operator's stored price never reached cost_meter (synth → wire → per-record lookup broken)")
// 8a-cost. Exact cost from the OPERATOR's stored price, not the catalog
// default: 12 prompt tokens × 0.004/1k + 40 completion tokens × 0.02/1k
// = 0.000048 + 0.0008 = 0.000848. With catalog rates it would be 0.00063
// — this assertion distinguishes the two, closing the loop on the whole
// dynamic-pricing feature (dashboard save → synth → gRPC wire →
// llm.resolved_provider_id lookup → billing).
assert.Equal(t, "0.000848000", cd.GetMetadata()["cost.usd_total"],
"cost must be computed from the provider record's operator-pinned price")
"Admins group consumption row must increment via the response leg — if this fails the proxy's respInput dropped UserGroups again or the parser/recorder wiring is broken")
// 8b. Both the captured prompt and the captured completion are
// redacted — proves the synth threads redact_pii=true into BOTH parser

View File

@@ -246,6 +246,10 @@ type Server struct {
// in processMappings before the receive loop reconnects to resync.
// Zero uses defaultMappingBatchWatchdog.
MappingBatchWatchdog time.Duration
// MiddlewareDataDir is the base directory the middleware system uses to
// resolve file-backed configuration (e.g. the cost_meter pricing table).
// Empty means any middleware that requires a file fails at configure time.
MiddlewareDataDir string
// MiddlewareCaptureBudgetBytes overrides the proxy-wide in-flight capture
// budget passed to middleware.NewManager. Zero or negative values fall
// back to defaultMiddlewareCaptureBudgetBytes (256 MiB).
@@ -2089,7 +2093,7 @@ func (s *Server) initMiddlewareManager(ctx context.Context) error {
return fmt.Errorf("middleware manager requires metrics bundle")
}
otelMeter := s.meter.Meter()
mwbuiltin.Configure(ctx, otelMeter, s.Logger, s.mgmtClient)
mwbuiltin.Configure(ctx, s.MiddlewareDataDir, otelMeter, s.Logger, s.mgmtClient)
mwMetrics, err := middleware.NewMetrics(otelMeter)
if err != nil {

View File

@@ -1,58 +0,0 @@
// Package llm holds LLM model-identifier helpers shared by the proxy and
// the management server. The proxy normalizes model ids parsed off inbound
// requests; management normalizes the operator's registered model ids at
// synthesis time so both sides of the pricing / routing contract compare
// equal.
package llm
import (
"regexp"
"strings"
)
// bedrockRegionPrefixes are the cross-region inference-profile prefixes that
// front a Bedrock model id (e.g. "eu.anthropic.claude-...").
var bedrockRegionPrefixes = []string{"us.", "eu.", "apac.", "global."}
// bedrockVersionSuffix matches the trailing "-vN[:N]" or "-YYYYMMDD-vN[:N]"
// version/throughput suffix of a Bedrock model id.
var bedrockVersionSuffix = regexp.MustCompile(`-(\d{8}-)?v\d+(:\d+)?$`)
// NormalizeBedrockModel strips an ARN wrapper, a cross-region inference-profile
// prefix, and the version/throughput suffix from a Bedrock model id so it
// matches the catalog/pricing key, e.g.
// "eu.anthropic.claude-sonnet-4-5-20250929-v1:0" -> "anthropic.claude-sonnet-4-5"
// and the inference-profile ARN's last segment likewise. It is the single
// source of truth shared by the proxy's request parser (which normalizes the
// request model from the URL path), the proxy's router (which normalizes the
// operator's registered Bedrock model ids so both sides compare equal), and
// the management synthesizer (which keys per-provider pricing entries by the
// normalized id the parser will emit at billing time).
func NormalizeBedrockModel(modelID string) string {
m := modelID
// A full ARN (inference-profile / provisioned-throughput / foundation-model)
// carries the model id in its last path segment.
if strings.HasPrefix(m, "arn:") {
if i := strings.LastIndex(m, "/"); i >= 0 {
m = m[i+1:]
}
}
for _, p := range bedrockRegionPrefixes {
if strings.HasPrefix(m, p) {
m = m[len(p):]
break
}
}
return bedrockVersionSuffix.ReplaceAllString(m, "")
}
// NormalizeVertexModel strips the "@version" suffix from a Vertex AI model id
// (e.g. "claude-sonnet-4-5@20250929" -> "claude-sonnet-4-5") so it matches
// the catalog/pricing key. Vertex publisher models are priced under their
// vendor surface with the bare, unversioned id.
func NormalizeVertexModel(modelID string) string {
if at := strings.Index(modelID, "@"); at >= 0 {
return modelID[:at]
}
return modelID
}

View File

@@ -5271,21 +5271,6 @@ components:
format: double
description: Cost per 1k output tokens, in USD.
example: 0.0006
cached_input_per_1k:
type: number
format: double
description: OpenAI-shape cache rate — cost per 1k cached prompt tokens (a subset of input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means no discount (cached tokens bill at input_per_1k).
example: 0.000075
cache_read_per_1k:
type: number
format: double
description: Anthropic-shape cache rate — cost per 1k cache-read tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache reads bill at input_per_1k.
example: 0.0003
cache_creation_per_1k:
type: number
format: double
description: Anthropic-shape cache rate — cost per 1k cache-creation tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache writes bill at input_per_1k.
example: 0.00375
required:
- id
- input_per_1k
@@ -5311,21 +5296,6 @@ components:
format: double
description: Output token price per 1k tokens, in USD.
example: 0.015
cached_input_per_1k:
type: number
format: double
description: OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount.
example: 0.000075
cache_read_per_1k:
type: number
format: double
description: Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate.
example: 0.0003
cache_creation_per_1k:
type: number
format: double
description: Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate.
example: 0.00375
context_window:
type: integer
description: Maximum context window in tokens.
@@ -5384,13 +5354,6 @@ components:
$ref: '#/components/schemas/AgentNetworkCatalogExtraHeader'
identity_injection:
$ref: '#/components/schemas/AgentNetworkCatalogIdentityInjection'
pricing_surfaces:
type: array
description: |
Cost-meter pricing surfaces this provider's traffic is metered under ("openai", "anthropic", "bedrock"). Tells the dashboard which cache-rate fields apply to this provider's models: "openai" → cached_input_per_1k (cached prompt tokens are a subset of input); "anthropic"/"bedrock" → cache_read_per_1k + cache_creation_per_1k (additive buckets). Absent/empty for gateway and custom entries, whose upstream shape NetBird cannot know ahead of time — surface all cache fields for those.
items:
type: string
example: ["openai"]
models:
type: array
description: Catalog models available for this provider.

View File

@@ -2017,15 +2017,6 @@ type AgentNetworkCatalogJSONMetadataInjection struct {
// AgentNetworkCatalogModel defines model for AgentNetworkCatalogModel.
type AgentNetworkCatalogModel struct {
// CacheCreationPer1k Anthropic-shape cache rate — default cost per 1k cache-creation tokens (additive to input tokens), in USD. Absent when the model has no cache-creation rate.
CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"`
// CacheReadPer1k Anthropic-shape cache rate — default cost per 1k cache-read tokens (additive to input tokens), in USD. Absent when the model has no cache-read rate.
CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"`
// CachedInputPer1k OpenAI-shape cache rate — default cost per 1k cached prompt tokens (a subset of input tokens), in USD. Absent when the model has no cached-input discount.
CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"`
// ContextWindow Maximum context window in tokens.
ContextWindow int `json:"context_window"`
@@ -2079,9 +2070,6 @@ type AgentNetworkCatalogProvider struct {
// Name Display name for the provider.
Name string `json:"name"`
// PricingSurfaces Cost-meter pricing surfaces this provider's traffic is metered under ("openai", "anthropic", "bedrock"). Tells the dashboard which cache-rate fields apply to this provider's models: "openai" → cached_input_per_1k (cached prompt tokens are a subset of input); "anthropic"/"bedrock" → cache_read_per_1k + cache_creation_per_1k (additive buckets). Absent/empty for gateway and custom entries, whose upstream shape NetBird cannot know ahead of time — surface all cache fields for those.
PricingSurfaces *[]string `json:"pricing_surfaces,omitempty"`
}
// AgentNetworkCatalogProviderKind Presentation grouping for the provider Select on the dashboard.
@@ -2305,15 +2293,6 @@ type AgentNetworkProvider struct {
// AgentNetworkProviderModel A model exposed by the provider, with the operator's per-1k input/output prices in USD.
type AgentNetworkProviderModel struct {
// CacheCreationPer1k Anthropic-shape cache rate — cost per 1k cache-creation tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache writes bill at input_per_1k.
CacheCreationPer1k *float64 `json:"cache_creation_per_1k,omitempty"`
// CacheReadPer1k Anthropic-shape cache rate — cost per 1k cache-read tokens (additive to input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means cache reads bill at input_per_1k.
CacheReadPer1k *float64 `json:"cache_read_per_1k,omitempty"`
// CachedInputPer1k OpenAI-shape cache rate — cost per 1k cached prompt tokens (a subset of input tokens), in USD. Omitted means inherit NetBird's default rate for this model when one exists; 0 means no discount (cached tokens bill at input_per_1k).
CachedInputPer1k *float64 `json:"cached_input_per_1k,omitempty"`
// Id Model identifier (e.g. "gpt-4o-mini").
Id string `json:"id"`