mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-18 12:39:54 +00:00
* [agent-network] Shared proto, OpenAPI schema, and generated types * [agent-network] Management: store, manager, synthesizer, policy engine, provider catalog, HTTP/gRPC API Adds the account-scoped agent-network module: provider/policy/budget CRUD and store, the reverse-proxy service synthesizer, policy selection + limit enforcement, the provider catalog (incl. Vertex AI and AWS Bedrock entries), and the management HTTP + proxy gRPC surfaces. * [management] Fix agent-network proxy-peer fan-out on affected-peer recompute The affected-peers resolver loaded only persisted reverse-proxy services, but agent-network services are synthesized on demand and never persisted. As a result the embedded proxy peer was never folded into the affected set when a client's group changed, so the proxy received no network-map update for a newly authorised client and rejected its handshake until a full resync (restart). loadProxyServices now merges the synthesized agent-network services (injected via a registration hook to avoid an import cycle), so proxy peers learn newly authorised clients immediately. * [proxy] Reverse-proxy middleware framework, chain, and request plumbing The per-target middleware chain (slots, dispatcher, mutation gate, metadata merger), body capture, access-log terminal sink, and the proxy wiring that builds + runs chains for synthesized agent-network services. * [proxy] LLM parsers, pricing, and builtin middlewares (OpenAI, Anthropic, Vertex AI, AWS Bedrock) Request/response parsers and SSE/event-stream metering, the embedded pricing table, and the builtin middleware set: request parser, router, policy limit-check/record, cost meter, guardrail, identity inject, response parser. Includes the path-routed providers — Google Vertex AI (keyfile:: service-account OAuth minting) and AWS Bedrock (bearer auth, invoke/converse/streaming, optional /bedrock prefix) — plus the Models allowlist and unmeterable-publisher deny. * [proxy] IPv6 in-place apply and TCP accept-loop hardening on netstack listeners * [agent-network] End-to-end test suite, module docs, and deployment preset * [agent-network] Fix codespell typos and exclude false positives - labelgen word pool: vermillion -> vermilion, racoon -> raccoon. - codespell ignore list: add flate (Go compress/flate package), recordin (a test-local identifier), and unparseable (a valid alternative spelling used consistently across identifiers + a metadata-value constant). * [management] Set LastSeen on injected proxy peer in realstack test (MySQL strict-mode) The injected embedded proxy peer had a PeerStatus with a zero LastSeen, which serializes to '0000-00-00' and is rejected by MySQL in strict mode (SQLite tolerates it). Set LastSeen to a valid time so SaveAccount succeeds on both engines. * [agent-network] Remove e2e shell-script suite from this branch The end-to-end shell scripts under scripts/e2e/ are maintained in a separate testing suite and are not part of this change set. * [agent-network] Polish module docs: remove internal review scaffolding, fix links, verify diagrams Strip PR-review framing, commit references, absolute paths, and stale internal references from the agent-network module docs; fix broken relative links; verify all diagrams against the current architecture. Remove the internal AI-reviewer prompt file. * [management] Refine session expiration handling to support 3-state encoding for SSO deadlines * [agent-network] Relocate agentnetwork package to internals/modules Move management/server/agentnetwork (and its catalog/, labelgen/, types/ subpackages) to management/internals/modules/agentnetwork, alongside the reverse-proxy module, and rewrite all importers. Pure relocation: package names, the synthesizer + affectedpeers registration hook, and store access (shared store.Store) are unchanged, so no import cycle is introduced (affectedpeers still depends only on the agentnetwork/types leaf). * [agent-network] Co-locate HTTP handlers in the module (RegisterEndpoints) Move the agent-network HTTP handlers from server/http/handlers/agentnetwork into the module at internals/modules/agentnetwork/handlers (package handlers) and rename the entrypoint AddEndpoints -> RegisterEndpoints, matching the reverse-proxy module convention. Wiring in http/handler.go updated accordingly.
450 lines
14 KiB
Go
450 lines
14 KiB
Go
// 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 (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. 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
|
|
CachedInputPer1K float64
|
|
CacheReadPer1K float64
|
|
CacheCreationPer1K float64
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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) {
|
|
// Clamp negatives to zero before any pricing math so a malformed
|
|
// upstream count can never produce a negative cost.
|
|
if inTokens < 0 {
|
|
inTokens = 0
|
|
}
|
|
if outTokens < 0 {
|
|
outTokens = 0
|
|
}
|
|
if cachedInput < 0 {
|
|
cachedInput = 0
|
|
}
|
|
if cacheCreation < 0 {
|
|
cacheCreation = 0
|
|
}
|
|
if t == nil {
|
|
return 0, false
|
|
}
|
|
byModel, ok := t.entries[provider]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
entry, ok := byModel[model]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
output := (float64(outTokens) / 1000.0) * entry.OutputPer1K
|
|
switch provider {
|
|
case "openai":
|
|
// cachedInput is a subset of inTokens; clamp so a malformed
|
|
// upstream (cached > total) can't produce a negative remainder.
|
|
clamped := cachedInput
|
|
if clamped > inTokens {
|
|
clamped = inTokens
|
|
}
|
|
cachedRate := entry.CachedInputPer1K
|
|
if cachedRate <= 0 {
|
|
cachedRate = entry.InputPer1K
|
|
}
|
|
nonCached := float64(inTokens-clamped) / 1000.0 * entry.InputPer1K
|
|
cached := float64(clamped) / 1000.0 * cachedRate
|
|
return nonCached + cached + output, true
|
|
case "anthropic", "bedrock":
|
|
// Bedrock-Anthropic returns the same additive cache buckets as
|
|
// first-party Anthropic; non-Anthropic Bedrock models simply report
|
|
// zero cache tokens, so this formula degrades to input + output.
|
|
readRate := entry.CacheReadPer1K
|
|
if readRate <= 0 {
|
|
readRate = entry.InputPer1K
|
|
}
|
|
createRate := entry.CacheCreationPer1K
|
|
if createRate <= 0 {
|
|
createRate = entry.InputPer1K
|
|
}
|
|
input := float64(inTokens) / 1000.0 * entry.InputPer1K
|
|
read := float64(cachedInput) / 1000.0 * readRate
|
|
create := float64(cacheCreation) / 1000.0 * createRate
|
|
return input + read + create + output, true
|
|
default:
|
|
input := float64(inTokens) / 1000.0 * entry.InputPer1K
|
|
return input + 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
|
|
}
|