first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
package governance
import (
"testing"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/stretchr/testify/assert"
)
// mockInMemoryStore is a test double for InMemoryStore.
type mockInMemoryStore struct {
allowAllClients map[string]string // clientID → clientName
configuredProviders map[schemas.ModelProvider]configstore.ProviderConfig
}
func (m *mockInMemoryStore) GetConfiguredProviders() map[schemas.ModelProvider]configstore.ProviderConfig {
return m.configuredProviders
}
func (m *mockInMemoryStore) GetMCPClientsAllowingAllVirtualKeys() map[string]string {
return m.allowAllClients
}
// newPluginWithInMemoryStore builds a minimal GovernancePlugin wired with a mock InMemoryStore.
func newPluginWithInMemoryStore(store InMemoryStore) *GovernancePlugin {
return &GovernancePlugin{inMemoryStore: store}
}
// buildVKWithMCPConfigs returns a VK that has explicit MCPConfigs for the given client.
func buildVKWithMCPConfigs(clientID, clientName string, tools []string) *configstoreTables.TableVirtualKey {
return &configstoreTables.TableVirtualKey{
ID: "vk-1",
Name: "test-vk",
MCPConfigs: []configstoreTables.TableVirtualKeyMCPConfig{
{
MCPClient: configstoreTables.TableMCPClient{
ClientID: clientID,
Name: clientName,
},
ToolsToExecute: tools,
},
},
}
}
// buildVKNoMCPConfigs returns a VK with no MCPConfigs at all.
func buildVKNoMCPConfigs() *configstoreTables.TableVirtualKey {
return &configstoreTables.TableVirtualKey{
ID: "vk-2",
Name: "test-vk-empty",
}
}
// ============================================================================
// isMCPToolAllowedByVKWith — AllowOnAllVirtualKeys scenarios
// ============================================================================
// VK with no MCPConfigs + AllowOnAllVirtualKeys client → tools allowed
func TestIsMCPToolAllowedByVKWith_NoVKConfig_AllowAllEnabled(t *testing.T) {
p := newPluginWithInMemoryStore(&mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
})
vk := buildVKNoMCPConfigs()
assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}),
"specific tool should be allowed when AllowOnAllVirtualKeys is set and VK has no explicit config")
assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}),
"wildcard pattern should be allowed when AllowOnAllVirtualKeys is set and VK has no explicit config")
}
// VK with explicit empty tools config for an AllowOnAllVirtualKeys client → tools blocked
func TestIsMCPToolAllowedByVKWith_ExplicitEmptyConfig_Blocks(t *testing.T) {
p := newPluginWithInMemoryStore(&mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
})
// Explicit VK config with empty tools list (deny-all for this client)
vk := buildVKWithMCPConfigs("client-1", "youtube", []string{})
assert.False(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}),
"explicit empty tools list should block access even when AllowOnAllVirtualKeys is set")
assert.False(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}),
"wildcard should be blocked when explicit config has empty tools list")
}
// VK with explicit ["tool1"] config for an AllowOnAllVirtualKeys client → only tool1 allowed
func TestIsMCPToolAllowedByVKWith_ExplicitPartialConfig_OnlyListedToolsAllowed(t *testing.T) {
p := newPluginWithInMemoryStore(&mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
})
vk := buildVKWithMCPConfigs("client-1", "youtube", []string{"search"})
assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}),
"explicitly listed tool should be allowed")
assert.False(t, p.isMCPToolAllowedByVKWith(vk, "youtube-upload", map[string]string{"client-1": "youtube"}),
"non-listed tool should be blocked even when AllowOnAllVirtualKeys is set")
}
// inMemoryStore is nil → AllowOnAllVirtualKeys clients are treated as not configured (all blocked)
func TestIsMCPToolAllowedByVKWith_NilInMemoryStore_AllBlocked(t *testing.T) {
p := &GovernancePlugin{inMemoryStore: nil}
vk := buildVKNoMCPConfigs()
allowed := p.isMCPToolAllowedByVKWith(vk, "youtube-search", nil)
assert.False(t, allowed,
"nil inMemoryStore means no AllowOnAllVirtualKeys clients; tool should be blocked")
}
// Wildcard pattern (clientName-*) with AllowOnAllVirtualKeys client and no VK config → allowed
func TestIsMCPToolAllowedByVKWith_WildcardPattern_AllowAll_NoVKConfig(t *testing.T) {
p := newPluginWithInMemoryStore(&mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
})
vk := buildVKNoMCPConfigs()
assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}),
"clientName-* wildcard should match AllowOnAllVirtualKeys fallback")
}
// Explicit unrestricted config (["*"]) for AllowOnAllVirtualKeys client → all tools allowed
func TestIsMCPToolAllowedByVKWith_ExplicitUnrestrictedConfig_AllowsAll(t *testing.T) {
p := newPluginWithInMemoryStore(&mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
})
vk := buildVKWithMCPConfigs("client-1", "youtube", []string{"*"})
assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-search", map[string]string{"client-1": "youtube"}),
"unrestricted explicit config should allow all tools")
assert.True(t, p.isMCPToolAllowedByVKWith(vk, "youtube-*", map[string]string{"client-1": "youtube"}),
"wildcard should match when explicit config is unrestricted")
}
// Tool belonging to a different client is not allowed via AllowOnAllVirtualKeys of another client
func TestIsMCPToolAllowedByVKWith_DifferentClient_Blocked(t *testing.T) {
p := newPluginWithInMemoryStore(&mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
})
vk := buildVKNoMCPConfigs()
assert.False(t, p.isMCPToolAllowedByVKWith(vk, "github-list_repos", map[string]string{"client-1": "youtube"}),
"tool from a different client should not be allowed via another client's AllowOnAllVirtualKeys")
}
// isMCPToolAllowedByVK delegates to inMemoryStore correctly
func TestIsMCPToolAllowedByVK_UsesInMemoryStore(t *testing.T) {
store := &mockInMemoryStore{
allowAllClients: map[string]string{"client-1": "youtube"},
}
p := newPluginWithInMemoryStore(store)
vk := buildVKNoMCPConfigs()
assert.True(t, p.isMCPToolAllowedByVK(vk, "youtube-search"),
"isMCPToolAllowedByVK should use inMemoryStore to resolve AllowOnAllVirtualKeys")
}
// isMCPToolAllowedByVK with nil inMemoryStore → blocked
func TestIsMCPToolAllowedByVK_NilStore_Blocked(t *testing.T) {
p := &GovernancePlugin{inMemoryStore: nil}
vk := buildVKNoMCPConfigs()
assert.False(t, p.isMCPToolAllowedByVK(vk, "youtube-search"),
"nil inMemoryStore should result in blocked access")
}

View File

164
plugins/governance/go.mod Normal file
View File

@@ -0,0 +1,164 @@
module github.com/maximhq/bifrost/plugins/governance
go 1.26.2
require gorm.io/gorm v1.31.1
require (
github.com/bytedance/sonic v1.15.0
github.com/google/cel-go v0.26.1
github.com/google/uuid v1.6.0
github.com/maximhq/bifrost/core v1.5.4
github.com/maximhq/bifrost/framework v1.3.4
github.com/stretchr/testify v1.11.1
github.com/valyala/fasthttp v1.68.0
)
require (
cel.dev/expr v0.25.1 // indirect
cloud.google.com/go v0.123.0 // indirect
cloud.google.com/go/auth v0.18.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.9.0 // indirect
cloud.google.com/go/iam v1.5.3 // indirect
cloud.google.com/go/monitoring v1.24.3 // indirect
cloud.google.com/go/storage v1.61.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.32.11 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/analysis v0.24.2 // indirect
github.com/go-openapi/errors v0.22.5 // indirect
github.com/go-openapi/jsonpointer v0.22.4 // indirect
github.com/go-openapi/jsonreference v0.21.4 // indirect
github.com/go-openapi/loads v0.23.2 // indirect
github.com/go-openapi/runtime v0.29.2 // indirect
github.com/go-openapi/spec v0.22.2 // indirect
github.com/go-openapi/strfmt v0.25.0 // indirect
github.com/go-openapi/swag v0.25.4 // indirect
github.com/go-openapi/swag/cmdutils v0.25.4 // indirect
github.com/go-openapi/swag/conv v0.25.4 // indirect
github.com/go-openapi/swag/fileutils v0.25.4 // indirect
github.com/go-openapi/swag/jsonname v0.25.4 // indirect
github.com/go-openapi/swag/jsonutils v0.25.4 // indirect
github.com/go-openapi/swag/loading v0.25.4 // indirect
github.com/go-openapi/swag/mangling v0.25.4 // indirect
github.com/go-openapi/swag/netutils v0.25.4 // indirect
github.com/go-openapi/swag/stringutils v0.25.4 // indirect
github.com/go-openapi/swag/typeutils v0.25.4 // indirect
github.com/go-openapi/swag/yamlutils v0.25.4 // indirect
github.com/go-openapi/validate v0.25.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.14 // indirect
github.com/googleapis/gax-go/v2 v2.19.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/mailru/easyjson v0.9.1 // indirect
github.com/mark3labs/mcp-go v0.43.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/oapi-codegen/runtime v1.1.1 // indirect
github.com/oklog/ulid v1.3.1 // indirect
github.com/pinecone-io/go-pinecone/v5 v5.3.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/qdrant/go-client v1.16.2 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stoewer/go-strcase v1.3.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/weaviate/weaviate v1.36.5 // indirect
github.com/weaviate/weaviate-go-client/v5 v5.7.1 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.mongodb.org/mongo-driver v1.17.6 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/detectors/gcp v1.40.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.starlark.net v0.0.0-20260102030733-3fee463870c9 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/api v0.274.0 // indirect
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gorm.io/driver/postgres v1.6.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
)

398
plugins/governance/go.sum Normal file
View File

@@ -0,0 +1,398 @@
cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4=
cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4=
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
cloud.google.com/go/logging v1.13.2 h1:qqlHCBvieJT9Cdq4QqYx1KPadCQ2noD4FK02eNqHAjA=
cloud.google.com/go/logging v1.13.2/go.mod h1:zaybliM3yun1J8mU2dVQ1/qDzjbOqEijZCn6hSBtKak=
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
cloud.google.com/go/monitoring v1.24.3 h1:dde+gMNc0UhPZD1Azu6at2e79bfdztVDS5lvhOdsgaE=
cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI=
cloud.google.com/go/storage v1.61.3 h1:VS//ZfBuPGDvakfD9xyPW1RGF1Vy3BWUoVZXgW1KMOg=
cloud.google.com/go/storage v1.61.3/go.mod h1:JtqK8BBB7TWv0HVGHubtUdzYYrakOQIsMLffZ2Z/HWk=
cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U=
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0 h1:DHa2U07rk8syqvCge0QIGMCE1WxGj9njT44GH7zNJLQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 h1:UnDZ/zFfG1JhH/DqxIZYU/1CUAlTUScoXD/LcM2Ykk8=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0 h1:7t/qx5Ost0s0wbA/VDrByOooURhp+ikYwv20i9Y07TQ=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.11 h1:ftxI5sgz8jZkckuUHXfC/wMUc8u3fG1vQS0plr2F2Zs=
github.com/aws/aws-sdk-go-v2/config v1.32.11/go.mod h1:twF11+6ps9aNRKEDimksp923o44w/Thk9+8YIlzWMmo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5 h1:clHU5fm//kWS1C2HgtgWxfQbFbx4b6rx+5jzhgX9HrI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.5/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3 h1:HwxWTbTrIHm5qY+CAEur0s/figc3qwvLWsNkF4RPToo=
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.3/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU=
github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g=
github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an9lx6VBE2cnb8wp1vEGNYGI=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4=
github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA=
github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE=
github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/analysis v0.24.2 h1:6p7WXEuKy1llDgOH8FooVeO+Uq2za9qoAOq4ZN08B50=
github.com/go-openapi/analysis v0.24.2/go.mod h1:x27OOHKANE0lutg2ml4kzYLoHGMKgRm1Cj2ijVOjJuE=
github.com/go-openapi/errors v0.22.5 h1:Yfv4O/PRYpNF3BNmVkEizcHb3uLVVsrDt3LNdgAKRY4=
github.com/go-openapi/errors v0.22.5/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk=
github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4=
github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80=
github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8=
github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4=
github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4=
github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY=
github.com/go-openapi/runtime v0.29.2 h1:UmwSGWNmWQqKm1c2MGgXVpC2FTGwPDQeUsBMufc5Yj0=
github.com/go-openapi/runtime v0.29.2/go.mod h1:biq5kJXRJKBJxTDJXAa00DOTa/anflQPhT0/wmjuy+0=
github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc=
github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs=
github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ=
github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8=
github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU=
github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ=
github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4=
github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4=
github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU=
github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y=
github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk=
github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI=
github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag=
github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA=
github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo=
github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM=
github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s=
github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE=
github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48=
github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg=
github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0=
github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg=
github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8=
github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0=
github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw=
github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE=
github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw=
github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4=
github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg=
github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls=
github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw=
github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ=
github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8=
github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE=
github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8=
github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mark3labs/mcp-go v0.43.2 h1:21PUSlWWiSbUPQwXIJ5WKlETixpFpq+WBpbMGDSVy/I=
github.com/mark3labs/mcp-go v0.43.2/go.mod h1:YnJfOL382MIWDx1kMY+2zsRHU/q78dBg9aFb8W6Thdw=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/maximhq/bifrost/core v1.5.4 h1:hf0BhoHVVpY1EQ4FkyRzW4IBYjrolxdZV0ucgWfHhcE=
github.com/maximhq/bifrost/core v1.5.4/go.mod h1:z1/vOalbDAD7v7sYbXQsqR+2qIFP0jKOSIStw6Q4P4U=
github.com/maximhq/bifrost/framework v1.3.4 h1:nZPv1FYry1njexZ0Hb6CZQXybwRFKGMTRyGWz2HGcio=
github.com/maximhq/bifrost/framework v1.3.4/go.mod h1:e0defDjWWFi6c2Zs3AOkMcRbYzjww4sjkyZtARrP4Zk=
github.com/oapi-codegen/runtime v1.1.1 h1:EXLHh0DXIJnWhdRPN2w4MXAzFyE4CskzhNLUmtpMYro=
github.com/oapi-codegen/runtime v1.1.1/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg=
github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pinecone-io/go-pinecone/v5 v5.3.0 h1:0YQlEtmXGWK/I8ztkOVM6PuBYgFJZhjSdb0ddU+bHPE=
github.com/pinecone-io/go-pinecone/v5 v5.3.0/go.mod h1:6Fg85fcyvMUQFf9KW7zniN81kelSYvsjF+KPLdc1MGA=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/qdrant/go-client v1.16.2 h1:UUMJJfvXTByhwhH1DwWdbkhZ2cTdvSqVkXSIfBrVWSg=
github.com/qdrant/go-client v1.16.2/go.mod h1:I+EL3h4HRoRTeHtbfOd/4kDXwCukZfkd41j/9wryGkw=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4=
github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs=
github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.68.0 h1:v12Nx16iepr8r9ySOwqI+5RBJ/DqTxhOy1HrHoDFnok=
github.com/valyala/fasthttp v1.68.0/go.mod h1:5EXiRfYQAoiO/khu4oU9VISC/eVY6JqmSpPJoHCKsz4=
github.com/weaviate/weaviate v1.36.5 h1:lCiuEfQ08+5wK0DkTCUBb6ayNep9QpBH6JJhmZaRfzk=
github.com/weaviate/weaviate v1.36.5/go.mod h1:ljzrgEmGKn3CRzDdcxvhmBUUZIcghwIYd1Lmn54f3Z8=
github.com/weaviate/weaviate-go-client/v5 v5.7.1 h1:vEMxh486QqRqWaq58UEe/TiTbGbo9T5x7ZPFd5QENvQ=
github.com/weaviate/weaviate-go-client/v5 v5.7.1/go.mod h1:T/JDErjN074GrnYIa0AgK1TGUGP/6A/8vqXNPlv4c6E=
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss=
go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/detectors/gcp v1.40.0 h1:Awaf8gmW99tZTOWqkLCOl6aw1/rxAWVlHsHIZ3fT2sA=
go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.starlark.net v0.0.0-20260102030733-3fee463870c9 h1:nV1OyvU+0CYrp5eKfQ3rD03TpFYYhH08z31NK1HmtTk=
go.starlark.net v0.0.0-20260102030733-3fee463870c9/go.mod h1:YKMCv9b1WrfWmeqdV5MAuEHWsu5iC+fe6kYl2sQjdI8=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.274.0 h1:aYhycS5QQCwxHLwfEHRRLf9yNsfvp1JadKKWBE54RFA=
google.golang.org/api v0.274.0/go.mod h1:JbAt7mF+XVmWu6xNP8/+CTiGH30ofmCmk9nM8d8fHew=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE=
google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=

View File

@@ -0,0 +1,369 @@
package governance
import (
"context"
"encoding/json"
"testing"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/maximhq/bifrost/framework/modelcatalog"
"github.com/stretchr/testify/require"
)
// TestHTTPTransportPreHook_VirtualKeyReplicateRefinesNestedModel verifies that
// virtual-key provider pinning rewrites the request model to Replicate's nested provider slug.
func TestHTTPTransportPreHook_VirtualKeyReplicateRefinesNestedModel(t *testing.T) {
logger := NewMockLogger()
mc := modelcatalog.NewTestCatalog(map[string]string{
"openai/gpt-5-nano": "gpt-5-nano",
})
mc.UpsertModelDataForProvider(schemas.Replicate, &schemas.BifrostListModelsResponse{
Data: []schemas.Model{
{ID: "replicate/openai/gpt-5-nano"},
},
}, nil)
virtualKey := buildVirtualKeyWithProviders(
"vk1",
"sk-bf-test",
"replicate-only",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("replicate", []string{"*"}),
},
)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey},
}, mc)
require.NoError(t, err)
plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, mc, nil, nil)
require.NoError(t, err)
defer func() {
require.NoError(t, plugin.Cleanup())
}()
req := schemas.AcquireHTTPRequest()
defer schemas.ReleaseHTTPRequest(req)
req.Method = "POST"
req.Path = "/v1/chat/completions"
req.Headers["Authorization"] = "Bearer sk-bf-test"
req.Headers["Content-Type"] = "application/json"
req.Body = []byte(`{"model":"gpt-5-nano","messages":[{"role":"user","content":"Hello!"}]}`)
bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := plugin.HTTPTransportPreHook(bfCtx, req)
require.NoError(t, err)
require.Nil(t, resp)
var payload struct {
Model string `json:"model"`
}
require.NoError(t, json.Unmarshal(req.Body, &payload))
require.Equal(t, "replicate/openai/gpt-5-nano", payload.Model)
}
// TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget verifies that when a routing rule
// matches on the /genai path, governance load balancing does not override the routing-rule target
// with a provider from the VK pool (regression test for issue #2516).
func TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget(t *testing.T) {
logger := NewMockLogger()
routingRule := configstoreTables.TableRoutingRule{
ID: "rule-genai-1",
Name: "genai-repro-rule",
Enabled: true,
CelExpression: `model == "probe-genai-model" && provider == ""`,
Targets: []configstoreTables.TableRoutingTarget{
{
RuleID: "rule-genai-1",
Provider: bifrost.Ptr("repro-openai-a"),
Model: bifrost.Ptr("error-test"),
Weight: 1.0,
},
},
Scope: "global",
Priority: 1,
}
// VK with repro-openai-b at weight=1 — this is what governance LB would wrongly select without the fix
virtualKey := buildVirtualKeyWithProviders(
"vk-genai",
"sk-bf-genai-test",
"genai-repro-vk",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("repro-openai-b", []string{"*"}),
},
)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey},
RoutingRules: []configstoreTables.TableRoutingRule{routingRule},
}, nil)
require.NoError(t, err)
plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil)
require.NoError(t, err)
defer func() {
require.NoError(t, plugin.Cleanup())
}()
req := schemas.AcquireHTTPRequest()
defer schemas.ReleaseHTTPRequest(req)
req.Method = "POST"
req.Path = "/genai/v1beta/models/probe-genai-model:generateContent"
req.PathParams["model"] = "probe-genai-model:generateContent"
req.Headers["Authorization"] = "Bearer sk-bf-genai-test"
req.Headers["Content-Type"] = "application/json"
req.Body = []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := plugin.HTTPTransportPreHook(bfCtx, req)
require.NoError(t, err)
require.Nil(t, resp)
// Routing rule matched and set context model to "repro-openai-a/error-test:generateContent".
// Governance LB must NOT override this with "repro-openai-b/probe-genai-model:generateContent".
ctxModel, ok := bfCtx.Value("model").(string)
require.True(t, ok, "context model should be set")
require.Equal(t, "repro-openai-a/error-test:generateContent", ctxModel)
}
// TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget_WithStore is a production-like variant
// of TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget that passes a non-nil inMemoryStore
// containing the routing-rule provider, confirming the fix holds when p.inMemoryStore != nil
// and the provider IS present in GetConfiguredProviders (the normal production code path).
func TestHTTPTransportPreHook_GenAIRoutingRulePreservesTarget_WithStore(t *testing.T) {
logger := NewMockLogger()
routingRule := configstoreTables.TableRoutingRule{
ID: "rule-genai-ws-1",
Name: "genai-repro-rule-with-store",
Enabled: true,
CelExpression: `model == "probe-genai-model" && provider == ""`,
Targets: []configstoreTables.TableRoutingTarget{
{
RuleID: "rule-genai-ws-1",
Provider: bifrost.Ptr("repro-openai-a"),
Model: bifrost.Ptr("error-test"),
Weight: 1.0,
},
},
Scope: "global",
Priority: 1,
}
virtualKey := buildVirtualKeyWithProviders(
"vk-genai-ws",
"sk-bf-genai-ws-test",
"genai-repro-vk-with-store",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("repro-openai-b", []string{"*"}),
},
)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey},
RoutingRules: []configstoreTables.TableRoutingRule{routingRule},
}, nil)
require.NoError(t, err)
// Register the fake provider so ParseModelString can split "repro-openai-a/model"
// the same way it would for a real provider in production.
schemas.RegisterKnownProvider("repro-openai-a")
t.Cleanup(func() { schemas.UnregisterKnownProvider("repro-openai-a") })
// Use a non-nil inMemoryStore that recognises the routing-rule provider,
// mirroring production where configured providers are always registered in the store.
inMemStore := &mockInMemoryStore{
configuredProviders: map[schemas.ModelProvider]configstore.ProviderConfig{
"repro-openai-a": {},
},
}
plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, inMemStore)
require.NoError(t, err)
defer func() {
require.NoError(t, plugin.Cleanup())
}()
req := schemas.AcquireHTTPRequest()
defer schemas.ReleaseHTTPRequest(req)
req.Method = "POST"
req.Path = "/genai/v1beta/models/probe-genai-model:generateContent"
req.PathParams["model"] = "probe-genai-model:generateContent"
req.Headers["Authorization"] = "Bearer sk-bf-genai-ws-test"
req.Headers["Content-Type"] = "application/json"
req.Body = []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := plugin.HTTPTransportPreHook(bfCtx, req)
require.NoError(t, err)
require.Nil(t, resp)
ctxModel, ok := bfCtx.Value("model").(string)
require.True(t, ok, "context model should be set")
require.Equal(t, "repro-openai-a/error-test:generateContent", ctxModel)
}
// TestHTTPTransportPreHook_GenAINoRoutingRuleStillLoadBalances verifies that when no routing rule
// matches on the /genai path, governance load balancing still selects a provider from the VK pool.
func TestHTTPTransportPreHook_GenAINoRoutingRuleStillLoadBalances(t *testing.T) {
logger := NewMockLogger()
// VK with repro-openai-b at weight=1 — LB should select this
virtualKey := buildVirtualKeyWithProviders(
"vk-genai-lb",
"sk-bf-genai-lb-test",
"genai-lb-vk",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("repro-openai-b", []string{"*"}),
},
)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey},
// No routing rules — governance LB should run normally
}, nil)
require.NoError(t, err)
plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil)
require.NoError(t, err)
defer func() {
require.NoError(t, plugin.Cleanup())
}()
req := schemas.AcquireHTTPRequest()
defer schemas.ReleaseHTTPRequest(req)
req.Method = "POST"
req.Path = "/genai/v1beta/models/probe-genai-model:generateContent"
req.PathParams["model"] = "probe-genai-model:generateContent"
req.Headers["Authorization"] = "Bearer sk-bf-genai-lb-test"
req.Headers["Content-Type"] = "application/json"
req.Body = []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := plugin.HTTPTransportPreHook(bfCtx, req)
require.NoError(t, err)
require.Nil(t, resp)
// No routing rule: governance LB must still run and select repro-openai-b from the VK pool
ctxModel, ok := bfCtx.Value("model").(string)
require.True(t, ok, "context model should be set by governance LB")
require.Equal(t, "repro-openai-b/probe-genai-model:generateContent", ctxModel)
}
// TestHTTPTransportPreHook_BedrockRoutingRulePreservesTarget verifies that when a routing rule
// matches on the /bedrock path, governance load balancing does not override the routing-rule target
// (regression test mirroring the GenAI fix for the Bedrock integration).
func TestHTTPTransportPreHook_BedrockRoutingRulePreservesTarget(t *testing.T) {
logger := NewMockLogger()
routingRule := configstoreTables.TableRoutingRule{
ID: "rule-bedrock-1",
Name: "bedrock-repro-rule",
Enabled: true,
CelExpression: `model == "probe-bedrock-model" && provider == ""`,
Targets: []configstoreTables.TableRoutingTarget{
{
RuleID: "rule-bedrock-1",
Provider: bifrost.Ptr("repro-openai-a"),
Model: bifrost.Ptr("error-test"),
Weight: 1.0,
},
},
Scope: "global",
Priority: 1,
}
// VK with repro-openai-b at weight=1 — this is what governance LB would wrongly select without the fix
virtualKey := buildVirtualKeyWithProviders(
"vk-bedrock",
"sk-bf-bedrock-test",
"bedrock-repro-vk",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("repro-openai-b", []string{"*"}),
},
)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey},
RoutingRules: []configstoreTables.TableRoutingRule{routingRule},
}, nil)
require.NoError(t, err)
plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil)
require.NoError(t, err)
defer func() {
require.NoError(t, plugin.Cleanup())
}()
req := schemas.AcquireHTTPRequest()
defer schemas.ReleaseHTTPRequest(req)
req.Method = "POST"
req.Path = "/bedrock/model/probe-bedrock-model/converse"
req.PathParams["modelId"] = "probe-bedrock-model"
req.Headers["Authorization"] = "Bearer sk-bf-bedrock-test"
req.Headers["Content-Type"] = "application/json"
req.Body = []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`)
bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := plugin.HTTPTransportPreHook(bfCtx, req)
require.NoError(t, err)
require.Nil(t, resp)
// Routing rule matched and set context modelId to "repro-openai-a/error-test".
// Governance LB must NOT override this with "repro-openai-b/probe-bedrock-model".
ctxModelID, ok := bfCtx.Value("modelId").(string)
require.True(t, ok, "context modelId should be set")
require.Equal(t, "repro-openai-a/error-test", ctxModelID)
}
// TestHTTPTransportPreHook_BedrockNoRoutingRuleStillLoadBalances verifies that when no routing rule
// matches on the /bedrock path, governance load balancing still selects a provider from the VK pool.
func TestHTTPTransportPreHook_BedrockNoRoutingRuleStillLoadBalances(t *testing.T) {
logger := NewMockLogger()
// VK with repro-openai-b at weight=1 — LB should select this
virtualKey := buildVirtualKeyWithProviders(
"vk-bedrock-lb",
"sk-bf-bedrock-lb-test",
"bedrock-lb-vk",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("repro-openai-b", []string{"*"}),
},
)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*virtualKey},
// No routing rules — governance LB should run normally
}, nil)
require.NoError(t, err)
plugin, err := InitFromStore(context.Background(), &Config{IsVkMandatory: boolPtr(false)}, logger, store, nil, nil, nil, nil)
require.NoError(t, err)
defer func() {
require.NoError(t, plugin.Cleanup())
}()
req := schemas.AcquireHTTPRequest()
defer schemas.ReleaseHTTPRequest(req)
req.Method = "POST"
req.Path = "/bedrock/model/probe-bedrock-model/converse"
req.PathParams["modelId"] = "probe-bedrock-model"
req.Headers["Authorization"] = "Bearer sk-bf-bedrock-lb-test"
req.Headers["Content-Type"] = "application/json"
req.Body = []byte(`{"messages":[{"role":"user","content":[{"text":"hi"}]}]}`)
bfCtx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
resp, err := plugin.HTTPTransportPreHook(bfCtx, req)
require.NoError(t, err)
require.Nil(t, resp)
// No routing rule: governance LB must still run and select repro-openai-b from the VK pool
ctxModelID, ok := bfCtx.Value("modelId").(string)
require.True(t, ok, "context modelId should be set by governance LB")
require.Equal(t, "repro-openai-b/probe-bedrock-model", ctxModelID)
}

1633
plugins/governance/main.go Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,472 @@
// Package governance provides the budget evaluation and decision engine
package governance
import (
"context"
"fmt"
"github.com/maximhq/bifrost/core/schemas"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/maximhq/bifrost/framework/modelcatalog"
)
// Decision represents the result of governance evaluation
type Decision string
const (
DecisionAllow Decision = "allow"
DecisionVirtualKeyNotFound Decision = "virtual_key_not_found"
DecisionVirtualKeyBlocked Decision = "virtual_key_blocked"
DecisionRateLimited Decision = "rate_limited"
DecisionBudgetExceeded Decision = "budget_exceeded"
DecisionTokenLimited Decision = "token_limited"
DecisionRequestLimited Decision = "request_limited"
DecisionModelBlocked Decision = "model_blocked"
DecisionProviderBlocked Decision = "provider_blocked"
DecisionMCPToolBlocked Decision = "mcp_tool_blocked"
)
// EvaluationRequest contains the context for evaluating a request
type EvaluationRequest struct {
VirtualKey string `json:"virtual_key"` // Virtual key value
Provider schemas.ModelProvider `json:"provider"`
Model string `json:"model"`
UserID string `json:"user_id,omitempty"` // User ID for user-level governance (enterprise only)
}
// EvaluationResult contains the complete result of governance evaluation
type EvaluationResult struct {
Decision Decision `json:"decision"`
Reason string `json:"reason"`
VirtualKey *configstoreTables.TableVirtualKey `json:"virtual_key,omitempty"`
RateLimitInfo *configstoreTables.TableRateLimit `json:"rate_limit_info,omitempty"`
BudgetInfo []*configstoreTables.TableBudget `json:"budget_info,omitempty"` // All budgets in hierarchy
UsageInfo *UsageInfo `json:"usage_info,omitempty"`
}
// UsageInfo represents current usage levels for rate limits and budgets
type UsageInfo struct {
// Rate limit usage
TokensUsedMinute int64 `json:"tokens_used_minute"`
TokensUsedHour int64 `json:"tokens_used_hour"`
TokensUsedDay int64 `json:"tokens_used_day"`
RequestsUsedMinute int64 `json:"requests_used_minute"`
RequestsUsedHour int64 `json:"requests_used_hour"`
RequestsUsedDay int64 `json:"requests_used_day"`
// Budget usage
VKBudgetUsage int64 `json:"vk_budget_usage"`
TeamBudgetUsage int64 `json:"team_budget_usage"`
CustomerBudgetUsage int64 `json:"customer_budget_usage"`
}
// BudgetResolver provides decision logic for the new hierarchical governance system
type BudgetResolver struct {
store GovernanceStore
logger schemas.Logger
modelCatalog *modelcatalog.ModelCatalog
governanceInMemoryStore InMemoryStore
}
// NewBudgetResolver creates a new budget-based governance resolver
func NewBudgetResolver(store GovernanceStore, modelCatalog *modelcatalog.ModelCatalog, logger schemas.Logger, governanceInMemoryStore InMemoryStore) *BudgetResolver {
return &BudgetResolver{
store: store,
logger: logger,
modelCatalog: modelCatalog,
governanceInMemoryStore: governanceInMemoryStore,
}
}
// EvaluateModelAndProviderRequest evaluates provider-level and model-level rate limits and budgets
// This applies even when virtual keys are disabled or not present
func (r *BudgetResolver) EvaluateModelAndProviderRequest(ctx *schemas.BifrostContext, provider schemas.ModelProvider, model string) *EvaluationResult {
// Create evaluation request for the checks
request := &EvaluationRequest{
Provider: provider,
Model: model,
}
// 1. Check provider-level rate limits FIRST (before model-level checks)
if provider != "" {
if decision, err := r.store.CheckProviderRateLimit(ctx, request, nil, nil); err != nil || isRateLimitViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Provider-level rate limit check failed: %s", reasonFromErr(err, decision)),
}
}
// 2. Check provider-level budgets FIRST (before model-level checks)
if decision, err := r.store.CheckProviderBudget(ctx, request, nil); err != nil || isBudgetViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Provider-level budget exceeded: %s", reasonFromErr(err, decision)),
}
}
}
// 3. Check model-level rate limits (after provider-level checks)
if model != "" {
if decision, err := r.store.CheckModelRateLimit(ctx, request, nil, nil); err != nil || isRateLimitViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Model-level rate limit check failed: %s", reasonFromErr(err, decision)),
}
}
// 4. Check model-level budgets (after provider-level checks)
if decision, err := r.store.CheckModelBudget(ctx, request, nil); err != nil || isBudgetViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Model-level budget exceeded: %s", reasonFromErr(err, decision)),
}
}
}
// All provider-level and model-level checks passed
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "Request allowed by governance policy (provider-level and model-level checks passed)",
}
}
func (r *BudgetResolver) EvaluateCustomerRequest(ctx *schemas.BifrostContext, customerID string, request *EvaluationRequest) *EvaluationResult {
// Skip if no customerID
if customerID == "" {
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "No customer ID provided, skipping customer-level checks",
}
}
// Check customer-level rate limits
if decision, err := r.store.CheckCustomerRateLimit(ctx, customerID, request, nil, nil); err != nil || isRateLimitViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Customer-level rate limit exceeded: %s", reasonFromErr(err, decision)),
}
}
// Check customer-level budget
if decision, err := r.store.CheckCustomerBudget(ctx, customerID, request, nil); err != nil || isBudgetViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Customer-level budget exceeded: %s", reasonFromErr(err, decision)),
}
}
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "Customer-level checks passed",
}
}
func (r *BudgetResolver) EvaluateTeamRequest(ctx *schemas.BifrostContext, teamID string, request *EvaluationRequest) *EvaluationResult {
// Skip if no teamID
if teamID == "" {
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "No team ID provided, skipping team-level checks",
}
}
// Check team-level rate limits
if decision, err := r.store.CheckTeamRateLimit(ctx, teamID, request, nil, nil); err != nil || isRateLimitViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Team-level rate limit exceeded: %s", reasonFromErr(err, decision)),
}
}
// Check team-level budget
if decision, err := r.store.CheckTeamBudget(ctx, teamID, request, nil); err != nil || isBudgetViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Team-level budget exceeded: %s", reasonFromErr(err, decision)),
}
}
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "Team-level checks passed",
}
}
// EvaluateUserRequest evaluates user-level rate limits and budgets (enterprise-only)
// This runs after provider/model checks but before VK checks
// Returns DecisionAllow if userID is empty or user has no governance configured
func (r *BudgetResolver) EvaluateUserRequest(ctx *schemas.BifrostContext, userID string, request *EvaluationRequest) *EvaluationResult {
// Skip if no userID (non-enterprise or anonymous request)
if userID == "" {
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "No user ID provided, skipping user-level checks",
}
}
// Check user-level rate limits
if decision, err := r.store.CheckUserRateLimit(ctx, userID, request, nil, nil); err != nil || isRateLimitViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("User-level rate limit exceeded: %s", reasonFromErr(err, decision)),
}
}
// Check user-level budget
if decision, err := r.store.CheckUserBudget(ctx, userID, request, nil); err != nil || isBudgetViolation(decision) {
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("User-level budget exceeded: %s", reasonFromErr(err, decision)),
}
}
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "User-level checks passed",
}
}
// isModelRequired checks if the requested model is required for this request
func (r *BudgetResolver) isModelRequired(requestType schemas.RequestType) bool {
// Here we will have to check for some requests which do not need model
// For example, batches, container, files requests
// For these requests, we will only check for provider filtering
if requestType == schemas.ListModelsRequest || requestType == schemas.MCPToolExecutionRequest || requestType == schemas.BatchCreateRequest || requestType == schemas.BatchListRequest || requestType == schemas.BatchRetrieveRequest || requestType == schemas.BatchCancelRequest || requestType == schemas.BatchResultsRequest || requestType == schemas.FileUploadRequest || requestType == schemas.FileListRequest || requestType == schemas.FileRetrieveRequest || requestType == schemas.FileDeleteRequest || requestType == schemas.FileContentRequest || requestType == schemas.ContainerCreateRequest || requestType == schemas.ContainerListRequest || requestType == schemas.ContainerRetrieveRequest || requestType == schemas.ContainerDeleteRequest || requestType == schemas.ContainerFileCreateRequest || requestType == schemas.ContainerFileListRequest || requestType == schemas.ContainerFileRetrieveRequest || requestType == schemas.ContainerFileContentRequest || requestType == schemas.ContainerFileDeleteRequest {
return false
}
return true
}
// EvaluateVirtualKeyRequest evaluates virtual key-specific checks including validation, filtering, rate limits, and budgets
// skipRateLimitsAndBudgets evaluates to true when we want to skip rate limits and budgets. This is used when user auth is present (user governance handles limits).
func (r *BudgetResolver) EvaluateVirtualKeyRequest(ctx *schemas.BifrostContext, virtualKeyValue string, provider schemas.ModelProvider, model string, requestType schemas.RequestType, skipRateLimitsAndBudgets bool) *EvaluationResult {
// 1. Validate virtual key exists and is active
vk, exists := r.store.GetVirtualKey(ctx, virtualKeyValue)
if !exists {
return &EvaluationResult{
Decision: DecisionVirtualKeyNotFound,
Reason: "Virtual key not found",
}
}
// Set virtual key id and name in context
ctx.SetValue(schemas.BifrostContextKeyGovernanceVirtualKeyID, vk.ID)
ctx.SetValue(schemas.BifrostContextKeyGovernanceVirtualKeyName, vk.Name)
if vk.Team != nil {
ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamID, vk.Team.ID)
ctx.SetValue(schemas.BifrostContextKeyGovernanceTeamName, vk.Team.Name)
if vk.Team.Customer != nil {
ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, vk.Team.Customer.ID)
ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerName, vk.Team.Customer.Name)
}
}
if vk.Customer != nil {
ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerID, vk.Customer.ID)
ctx.SetValue(schemas.BifrostContextKeyGovernanceCustomerName, vk.Customer.Name)
}
if !vk.IsActive {
return &EvaluationResult{
Decision: DecisionVirtualKeyBlocked,
Reason: "Virtual key is inactive",
}
}
// 2. Check provider filtering
if requestType != schemas.MCPToolExecutionRequest && !r.isProviderAllowed(vk, provider) {
return &EvaluationResult{
Decision: DecisionProviderBlocked,
Reason: fmt.Sprintf("Provider '%s' is not allowed for this virtual key", provider),
VirtualKey: vk,
}
}
// 3. Check model filtering
if r.isModelRequired(requestType) && !r.isModelAllowed(vk, provider, model) {
return &EvaluationResult{
Decision: DecisionModelBlocked,
Reason: fmt.Sprintf("Model '%s' is not allowed for this virtual key", model),
VirtualKey: vk,
}
}
evaluationRequest := &EvaluationRequest{
VirtualKey: virtualKeyValue,
Provider: provider,
Model: model,
}
// 4. Check rate limits hierarchy (VK level)
if !skipRateLimitsAndBudgets {
if rateLimitResult := r.checkRateLimitHierarchy(ctx, vk, evaluationRequest); rateLimitResult != nil {
return rateLimitResult
}
// 5. Check budget hierarchy (VK → Team → Customer)
if budgetResult := r.checkBudgetHierarchy(ctx, vk, evaluationRequest); budgetResult != nil {
return budgetResult
}
}
// Find the provider config that matches the request's provider and apply key filtering
for _, pc := range vk.ProviderConfigs {
if schemas.ModelProvider(pc.Provider) == provider {
if !pc.AllowAllKeys {
// Restrict to specific keys (empty slice = no keys allowed)
includeOnlyKeys := make([]string, 0, len(pc.Keys))
for _, dbKey := range pc.Keys {
includeOnlyKeys = append(includeOnlyKeys, dbKey.KeyID)
}
ctx.SetValue(schemas.BifrostContextKeyGovernanceIncludeOnlyKeys, includeOnlyKeys)
}
break
}
}
// All checks passed
return &EvaluationResult{
Decision: DecisionAllow,
Reason: "Request allowed by governance policy",
VirtualKey: vk,
}
}
// isModelAllowed checks if the requested model is allowed for this VK
func (r *BudgetResolver) isModelAllowed(vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider, model string) bool {
// Empty ProviderConfigs means no models are allowed (deny-by-default)
if len(vk.ProviderConfigs) == 0 {
return false
}
for _, pc := range vk.ProviderConfigs {
if pc.Provider == string(provider) {
// Delegate model allowance check to model catalog
// This handles all cross-provider logic (OpenRouter, Vertex, Groq, Bedrock)
// and provider-prefixed allowed_models entries
if r.modelCatalog != nil && r.governanceInMemoryStore != nil {
providerConfig, ok := r.governanceInMemoryStore.GetConfiguredProviders()[provider]
providerConfigPtr := &providerConfig
if !ok {
providerConfigPtr = nil
}
return r.modelCatalog.IsModelAllowedForProvider(provider, model, providerConfigPtr, pc.AllowedModels)
}
// Fallback when model catalog is not available: simple string matching
// ["*"] = allow all models; [] = deny all models
return pc.AllowedModels.IsAllowed(model)
}
}
return false
}
// isProviderAllowed checks if the requested provider is allowed for this VK
func (r *BudgetResolver) isProviderAllowed(vk *configstoreTables.TableVirtualKey, provider schemas.ModelProvider) bool {
// Empty ProviderConfigs means no providers are allowed (deny-by-default)
if len(vk.ProviderConfigs) == 0 {
return false
}
for _, pc := range vk.ProviderConfigs {
if pc.Provider == string(provider) {
return true
}
}
return false
}
// checkRateLimitHierarchy checks provider-level rate limits first, then VK rate limits using flexible approach
func (r *BudgetResolver) checkRateLimitHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest) *EvaluationResult {
if decision, err := r.store.CheckVirtualKeyRateLimit(ctx, vk, request, nil, nil); err != nil || isRateLimitViolation(decision) {
// Check provider-level first (matching check order), then VK-level
var rateLimitInfo *configstoreTables.TableRateLimit
for _, pc := range vk.ProviderConfigs {
if pc.Provider == string(request.Provider) && pc.RateLimit != nil {
rateLimitInfo = pc.RateLimit
break
}
}
if rateLimitInfo == nil && vk.RateLimit != nil {
rateLimitInfo = vk.RateLimit
}
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Rate limit check failed: %s", reasonFromErr(err, decision)),
VirtualKey: vk,
RateLimitInfo: rateLimitInfo,
}
}
return nil // No rate limit violations
}
// checkBudgetHierarchy checks the budget hierarchy atomically (VK → Team → Customer)
func (r *BudgetResolver) checkBudgetHierarchy(ctx context.Context, vk *configstoreTables.TableVirtualKey, request *EvaluationRequest) *EvaluationResult {
// Use atomic budget checking to prevent race conditions
if decision, err := r.store.CheckVirtualKeyBudget(ctx, vk, request, nil); err != nil || isBudgetViolation(decision) {
r.logger.Debug(fmt.Sprintf("Atomic budget exceeded for VK %s: %s", vk.ID, reasonFromErr(err, decision)))
return &EvaluationResult{
Decision: decision,
Reason: fmt.Sprintf("Budget exceeded: %s", reasonFromErr(err, decision)),
VirtualKey: vk,
}
}
return nil // No budget violations
}
// Helper methods for provider config validation (used by TransportInterceptor)
// isProviderBudgetViolated checks if a provider config's budget is violated
func (r *BudgetResolver) isProviderBudgetViolated(ctx context.Context, vk *configstoreTables.TableVirtualKey, config configstoreTables.TableVirtualKeyProviderConfig) bool {
request := &EvaluationRequest{Provider: schemas.ModelProvider(config.Provider)}
// 1. Check global provider-level budget first
if _, err := r.store.CheckProviderBudget(ctx, request, nil); err != nil {
r.logger.Debug(fmt.Sprintf("Global provider budget exceeded for provider %s: %s", config.Provider, err.Error()))
return true
}
// 2. Check VK-level provider config budget
if len(config.Budgets) == 0 {
return false
}
if _, err := r.store.CheckVirtualKeyBudget(ctx, vk, request, nil); err != nil {
r.logger.Debug(fmt.Sprintf("VK provider config budget exceeded for VK %s: %s", vk.ID, err.Error()))
return true
}
return false
}
// isProviderRateLimitViolated checks if a provider config's rate limit is violated
func (r *BudgetResolver) isProviderRateLimitViolated(ctx context.Context, vk *configstoreTables.TableVirtualKey, config configstoreTables.TableVirtualKeyProviderConfig) bool {
request := &EvaluationRequest{Provider: schemas.ModelProvider(config.Provider)}
// 1. Check global provider-level rate limit first
if decision, err := r.store.CheckProviderRateLimit(ctx, request, nil, nil); err != nil || isRateLimitViolation(decision) {
r.logger.Debug(fmt.Sprintf("Global provider rate limit exceeded for provider %s", config.Provider))
return true
}
// 2. Check VK-level provider config rate limit
if config.RateLimit == nil {
return false
}
decision, err := r.store.CheckVirtualKeyRateLimit(ctx, vk, request, nil, nil)
if err != nil || isRateLimitViolation(decision) {
r.logger.Debug(fmt.Sprintf("VK provider config rate limit exceeded for VK %s, provider %s", vk.ID, config.Provider))
return true
}
return false
}
// isRateLimitViolation returns true if the decision indicates a rate limit violation
func isRateLimitViolation(decision Decision) bool {
return decision == DecisionRateLimited || decision == DecisionTokenLimited || decision == DecisionRequestLimited
}
// isBudgetViolation returns true if the decision indicates a budget violation.
func isBudgetViolation(decision Decision) bool {
return decision == DecisionBudgetExceeded
}
// reasonFromErr yields a non-nil-safe reason string. When the store returns a
// non-allow decision without an accompanying error, err.Error() would panic —
// fall back to a generic phrase that still names the decision.
func reasonFromErr(err error, decision Decision) string {
if err != nil {
return err.Error()
}
return fmt.Sprintf("policy violation (%s)", decision)
}

View File

@@ -0,0 +1,506 @@
package governance
import (
"context"
"testing"
"time"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestBudgetResolver_EvaluateRequest_AllowedRequest tests happy path
func TestBudgetResolver_EvaluateRequest_AllowedRequest(t *testing.T) {
logger := NewMockLogger()
vk := buildVirtualKey("vk1", "sk-bf-test", "Test VK", true)
vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"*"}),
}
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionAllow, result)
assertVirtualKeyFound(t, result)
}
// TestBudgetResolver_EvaluateRequest_VirtualKeyNotFound tests missing VK
func TestBudgetResolver_EvaluateRequest_VirtualKeyNotFound(t *testing.T) {
logger := NewMockLogger()
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-nonexistent", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionVirtualKeyNotFound, result)
}
// TestBudgetResolver_EvaluateRequest_VirtualKeyBlocked tests inactive VK
func TestBudgetResolver_EvaluateRequest_VirtualKeyBlocked(t *testing.T) {
logger := NewMockLogger()
vk := buildVirtualKey("vk1", "sk-bf-test", "Test VK", false) // Inactive
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionVirtualKeyBlocked, result)
}
// TestBudgetResolver_EvaluateRequest_ProviderBlocked tests provider filtering
func TestBudgetResolver_EvaluateRequest_ProviderBlocked(t *testing.T) {
logger := NewMockLogger()
// VK with only Anthropic allowed
providerConfigs := []configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("anthropic", []string{"claude-3-sonnet"}),
}
vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", providerConfigs)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
// Try to use OpenAI (not allowed)
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionProviderBlocked, result)
assertVirtualKeyFound(t, result)
}
// TestBudgetResolver_EvaluateRequest_ModelBlocked tests model filtering
func TestBudgetResolver_EvaluateRequest_ModelBlocked(t *testing.T) {
logger := NewMockLogger()
// VK with specific models allowed
providerConfigs := []configstoreTables.TableVirtualKeyProviderConfig{
{
Provider: "openai",
AllowedModels: []string{"gpt-4", "gpt-4-turbo"}, // Only these models
Weight: bifrost.Ptr(1.0),
RateLimit: nil,
Keys: []configstoreTables.TableKey{},
},
}
vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", providerConfigs)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
// Try to use gpt-4o-mini (not in allowed list)
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4o-mini", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionModelBlocked, result)
}
// TestBudgetResolver_EvaluateRequest_RateLimitExceeded_TokenLimit tests token limit
func TestBudgetResolver_EvaluateRequest_RateLimitExceeded_TokenLimit(t *testing.T) {
logger := NewMockLogger()
// VK with rate limit already at max
rateLimit := buildRateLimitWithUsage("rl1", 10000, 10000, 1000, 0) // Tokens at max
vk := buildVirtualKeyWithRateLimit("vk1", "sk-bf-test", "Test VK", rateLimit)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
RateLimits: []configstoreTables.TableRateLimit{*rateLimit},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionTokenLimited, result)
assertRateLimitInfo(t, result)
}
// TestBudgetResolver_EvaluateRequest_RateLimitExceeded_RequestLimit tests request limit
func TestBudgetResolver_EvaluateRequest_RateLimitExceeded_RequestLimit(t *testing.T) {
logger := NewMockLogger()
// VK with request limit already at max
rateLimit := buildRateLimitWithUsage("rl1", 10000, 0, 100, 100) // Requests at max
vk := buildVirtualKeyWithRateLimit("vk1", "sk-bf-test", "Test VK", rateLimit)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
RateLimits: []configstoreTables.TableRateLimit{*rateLimit},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionRequestLimited, result)
}
// TestBudgetResolver_EvaluateRequest_RateLimitExpired tests rate limit reset
func TestBudgetResolver_EvaluateRequest_RateLimitExpired(t *testing.T) {
logger := NewMockLogger()
// VK with rate limit that's expired (should be treated as reset)
duration := "1m"
rateLimit := &configstoreTables.TableRateLimit{
ID: "rl1",
TokenMaxLimit: ptrInt64(10000),
TokenCurrentUsage: 10000, // At limit
TokenResetDuration: &duration,
TokenLastReset: time.Now().Add(-2 * time.Minute), // Expired
RequestMaxLimit: ptrInt64(1000),
RequestCurrentUsage: 0,
RequestResetDuration: &duration,
RequestLastReset: time.Now(),
}
vk := buildVirtualKeyWithRateLimit("vk1", "sk-bf-test", "Test VK", rateLimit)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
RateLimits: []configstoreTables.TableRateLimit{*rateLimit},
}, nil)
require.NoError(t, err)
// Reset expired rate limits (simulating ticker behavior)
expiredRateLimits := store.ResetExpiredRateLimitsInMemory(context.Background())
err = store.ResetExpiredRateLimits(context.Background(), expiredRateLimits)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
// Should allow because rate limit was expired and has been reset
assertDecision(t, DecisionAllow, result)
}
// TestBudgetResolver_EvaluateRequest_BudgetExceeded tests budget violation
func TestBudgetResolver_EvaluateRequest_BudgetExceeded(t *testing.T) {
logger := NewMockLogger()
budget := buildBudgetWithUsage("budget1", 100.0, 100.0, "1d") // At limit
vk := buildVirtualKeyWithBudget("vk1", "sk-bf-test", "Test VK", budget)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
Budgets: []configstoreTables.TableBudget{*budget},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionBudgetExceeded, result)
}
// TestBudgetResolver_EvaluateRequest_BudgetExpired tests expired budget (should be treated as reset)
func TestBudgetResolver_EvaluateRequest_BudgetExpired(t *testing.T) {
logger := NewMockLogger()
budget := &configstoreTables.TableBudget{
ID: "budget1",
MaxLimit: 100.0,
CurrentUsage: 100.0, // At limit
ResetDuration: "1d",
LastReset: time.Now().Add(-48 * time.Hour), // Expired
}
vk := buildVirtualKeyWithBudget("vk1", "sk-bf-test", "Test VK", budget)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
Budgets: []configstoreTables.TableBudget{*budget},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
// Should allow because budget is expired (will be reset)
assertDecision(t, DecisionAllow, result)
}
// TestBudgetResolver_EvaluateRequest_MultiLevelBudgetHierarchy tests hierarchy checking
func TestBudgetResolver_EvaluateRequest_MultiLevelBudgetHierarchy(t *testing.T) {
logger := NewMockLogger()
vkBudget := buildBudgetWithUsage("vk-budget", 100.0, 50.0, "1d")
teamBudget := buildBudgetWithUsage("team-budget", 500.0, 200.0, "1d")
customerBudget := buildBudgetWithUsage("customer-budget", 1000.0, 400.0, "1d")
team := buildTeam("team1", "Team 1", teamBudget)
customer := buildCustomer("customer1", "Customer 1", customerBudget)
team.CustomerID = &customer.ID
team.Customer = customer
vk := buildVirtualKeyWithBudget("vk1", "sk-bf-test", "Test VK", vkBudget)
vk.TeamID = &team.ID
vk.Team = team
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
Budgets: []configstoreTables.TableBudget{*vkBudget, *teamBudget, *customerBudget},
Teams: []configstoreTables.TableTeam{*team},
Customers: []configstoreTables.TableCustomer{*customer},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
// Test: All under limit should pass
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionAllow, result)
// Test: VK budget exceeds should fail
// Get the governance data to update the budget directly
governanceData := store.GetGovernanceData(context.Background())
vkBudgetToUpdate := governanceData.Budgets["vk-budget"]
if vkBudgetToUpdate != nil {
vkBudgetToUpdate.CurrentUsage = 100.0
store.budgets.Store("vk-budget", vkBudgetToUpdate)
}
result = resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionBudgetExceeded, result)
}
// TestBudgetResolver_EvaluateRequest_ProviderLevelRateLimit tests provider-specific rate limits
func TestBudgetResolver_EvaluateRequest_ProviderLevelRateLimit(t *testing.T) {
logger := NewMockLogger()
// Provider with rate limit at max
providerRL := buildRateLimitWithUsage("provider-rl", 5000, 5000, 500, 0)
providerConfig := buildProviderConfigWithRateLimit("openai", []string{"gpt-4"}, providerRL)
vk := buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test VK", []configstoreTables.TableVirtualKeyProviderConfig{providerConfig})
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
RateLimits: []configstoreTables.TableRateLimit{*providerRL},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionTokenLimited, result)
assertRateLimitInfo(t, result)
}
// TestBudgetResolver_CheckRateLimits_BothExceeded tests token and request limits simultaneously
func TestBudgetResolver_CheckRateLimits_BothExceeded(t *testing.T) {
logger := NewMockLogger()
// Rate limit with both token and request at max
rateLimit := buildRateLimitWithUsage("rl1", 1000, 1000, 100, 100)
vk := buildVirtualKeyWithRateLimit("vk1", "sk-bf-test", "Test VK", rateLimit)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
RateLimits: []configstoreTables.TableRateLimit{*rateLimit},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assertDecision(t, DecisionRateLimited, result)
assert.Contains(t, result.Reason, "rate limit")
}
// TestBudgetResolver_IsProviderAllowed tests provider filtering logic
func TestBudgetResolver_IsProviderAllowed(t *testing.T) {
logger := NewMockLogger()
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
tests := []struct {
name string
vk *configstoreTables.TableVirtualKey
provider schemas.ModelProvider
shouldBeAllowed bool
}{
{
name: "No provider configs (none allowed - deny-by-default)",
vk: buildVirtualKey("vk1", "sk-bf-test", "Test", true),
provider: schemas.OpenAI,
shouldBeAllowed: false,
},
{
name: "Provider in allowlist",
vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"gpt-4"}),
}),
provider: schemas.OpenAI,
shouldBeAllowed: true,
},
{
name: "Provider not in allowlist",
vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("anthropic", []string{"claude-3-sonnet"}),
}),
provider: schemas.OpenAI,
shouldBeAllowed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := resolver.isProviderAllowed(tt.vk, tt.provider)
assert.Equal(t, tt.shouldBeAllowed, allowed)
})
}
}
// TestBudgetResolver_IsModelAllowed tests model filtering logic
func TestBudgetResolver_IsModelAllowed(t *testing.T) {
logger := NewMockLogger()
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
tests := []struct {
name string
vk *configstoreTables.TableVirtualKey
provider schemas.ModelProvider
model string
shouldBeAllowed bool
}{
{
name: "No provider configs (no models allowed - deny-by-default)",
vk: buildVirtualKey("vk1", "sk-bf-test", "Test", true),
provider: schemas.OpenAI,
model: "gpt-4",
shouldBeAllowed: false,
},
{
name: "Wildcard allowed models (all models allowed)",
vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"*"}), // ["*"] = allow all
}),
provider: schemas.OpenAI,
model: "gpt-4",
shouldBeAllowed: true,
},
{
name: "Empty allowed models (deny all)",
vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{}), // [] = deny all
}),
provider: schemas.OpenAI,
model: "gpt-4",
shouldBeAllowed: false,
},
{
name: "Model in allowlist",
vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"gpt-4", "gpt-4-turbo"}),
}),
provider: schemas.OpenAI,
model: "gpt-4",
shouldBeAllowed: true,
},
{
name: "Model not in allowlist",
vk: buildVirtualKeyWithProviders("vk1", "sk-bf-test", "Test",
[]configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"gpt-4", "gpt-4-turbo"}),
}),
provider: schemas.OpenAI,
model: "gpt-4o-mini",
shouldBeAllowed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
allowed := resolver.isModelAllowed(tt.vk, tt.provider, tt.model)
assert.Equal(t, tt.shouldBeAllowed, allowed)
})
}
}
// TestBudgetResolver_ContextPopulation tests context values are set correctly
func TestBudgetResolver_ContextPopulation(t *testing.T) {
logger := NewMockLogger()
vk := buildVirtualKey("vk1", "sk-bf-test", "Test VK", true)
vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"*"}),
}
customer := buildCustomer("cust1", "Customer 1", nil)
team := buildTeam("team1", "Team 1", nil)
team.CustomerID = &customer.ID
team.Customer = customer
vk.TeamID = &team.ID
vk.Team = team
vk.CustomerID = &customer.ID
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
Teams: []configstoreTables.TableTeam{*team},
Customers: []configstoreTables.TableCustomer{*customer},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
ctx := &schemas.BifrostContext{}
result := resolver.EvaluateVirtualKeyRequest(ctx, "sk-bf-test", schemas.OpenAI, "gpt-4", schemas.ChatCompletionRequest, false)
assert.Equal(t, DecisionAllow, result.Decision)
// Check context was populated
vkID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceVirtualKeyID).(string)
teamID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceTeamID).(string)
customerID, _ := ctx.Value(schemas.BifrostContextKeyGovernanceCustomerID).(string)
assert.Equal(t, "vk1", vkID)
assert.Equal(t, "team1", teamID)
assert.Equal(t, "cust1", customerID)
}

View File

@@ -0,0 +1,497 @@
package governance
import (
"fmt"
"math/rand/v2"
"strings"
"github.com/google/cel-go/cel"
"github.com/maximhq/bifrost/core/schemas"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
)
// DefaultRoutingChainMaxDepth is the default maximum depth for routing rule chain evaluation.
const DefaultRoutingChainMaxDepth = 10
// ScopeLevel represents a level in the scope precedence hierarchy
type ScopeLevel struct {
ScopeName string // "virtual_key", "team", "customer", or "global"
ScopeID string // empty string for global scope
}
// RoutingDecision is the output of routing rule evaluation
// Represents which provider/model to route to and fallback chain
type RoutingDecision struct {
Provider string // Primary provider (e.g., "openai", "azure")
Model string // Model to use (or empty to use original)
KeyID string // Optional: pin a specific API key by UUID ("" = no pin)
Fallbacks []string // Fallback chain: ["provider/model", ...]
MatchedRuleID string // ID of the rule that matched
MatchedRuleName string // Name of the rule that matched
}
// RoutingContext holds all data needed for routing rule evaluation
// Reuses existing configstore table types for VirtualKey, Team, Customer
type RoutingContext struct {
VirtualKey *configstoreTables.TableVirtualKey // nil if no VK
Provider schemas.ModelProvider // Current provider
Model string // Current model
RequestType string // Normalized request type (e.g., "chat_completion", "embedding") from HTTP context
Fallbacks []string // Fallback chain: ["provider/model", ...]
Headers map[string]string // Request headers for dynamic routing
QueryParams map[string]string // Query parameters for dynamic routing
BudgetAndRateLimitStatus *BudgetAndRateLimitStatus // Budget and rate limit status by provider/model
}
type RoutingEngine struct {
store GovernanceStore
logger schemas.Logger
chainMaxDepth *int // pointer to live config value; changes are reflected immediately
}
// NewRoutingEngine creates a new RoutingEngine
func NewRoutingEngine(store GovernanceStore, logger schemas.Logger, chainMaxDepth *int) (*RoutingEngine, error) {
if store == nil {
return nil, fmt.Errorf("store cannot be nil")
}
if logger == nil {
return nil, fmt.Errorf("logger cannot be nil")
}
if chainMaxDepth == nil {
return nil, fmt.Errorf("chainMaxDepth cannot be nil")
}
if *chainMaxDepth <= 0 {
return nil, fmt.Errorf("chainMaxDepth must be greater than 0")
}
return &RoutingEngine{
store: store,
logger: logger,
chainMaxDepth: chainMaxDepth,
}, nil
}
// EvaluateRoutingRules evaluates routing rules for a given context and returns a routing decision.
// Implements scope precedence: VirtualKey > Team > Customer > Global (first-match-wins within each iteration).
// When a matched rule has chain_rule=true, the resolved provider/model is fed back into the evaluator
// and the full scope chain is re-evaluated with the updated context. This repeats until:
// 1. No rule matches the current context
// 2. A terminal rule matches (chain_rule=false, the default)
// 3. A cycle is detected (a provider/model state was already visited)
// 4. The chain exceeds the configured max depth (chainMaxDepth, default 10)
func (re *RoutingEngine) EvaluateRoutingRules(ctx *schemas.BifrostContext, routingCtx *RoutingContext) (*RoutingDecision, error) {
if routingCtx == nil {
return nil, fmt.Errorf("routing context cannot be nil")
}
re.logger.Debug("[RoutingEngine] Starting rule evaluation for provider=%s, model=%s", routingCtx.Provider, routingCtx.Model)
// Mutable provider/model that advances through the chain; all other context fields are immutable.
currentProvider := routingCtx.Provider
currentModel := routingCtx.Model
// Track visited provider/model states to detect cycles (e.g. A→B→A).
visited := map[string]struct{}{
fmt.Sprintf("%s|%s", currentProvider, currentModel): {},
}
var finalDecision *RoutingDecision
for chainStep := 0; ; chainStep++ {
// TERMINATION 4: Chain exceeded configured max depth.
maxDepth := *re.chainMaxDepth
if chainStep >= maxDepth {
re.logger.Warn("[RoutingEngine] Routing rule chain exceeded max depth (%d), stopping", maxDepth)
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Chain exceeded max depth (%d) at step %d, stopping. Final resolved: provider=%s, model=%s", maxDepth, chainStep, currentProvider, currentModel))
break
}
if chainStep > 0 {
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Chain step %d: re-evaluating with provider=%s, model=%s", chainStep, currentProvider, currentModel))
}
// Build CEL variables for the current chain step's provider/model.
iterCtx := *routingCtx
iterCtx.Provider = currentProvider
iterCtx.Model = currentModel
// Refresh budget/rate-limit status for the current provider/model so chained
// rules that test budget_used, tokens_used, or request see fresh data.
iterCtx.BudgetAndRateLimitStatus = re.store.GetBudgetAndRateLimitStatus(ctx, currentModel, currentProvider, routingCtx.VirtualKey, nil, nil, nil)
variables, err := extractRoutingVariables(&iterCtx)
if err != nil {
re.logger.Error("[RoutingEngine] Failed to extract routing variables: %v", err)
return nil, fmt.Errorf("failed to extract routing variables: %w", err)
}
scopeChain := buildScopeChain(routingCtx.VirtualKey)
re.logger.Debug("[RoutingEngine] Scope chain (step=%d): %v", chainStep, scopeChainToStrings(scopeChain))
if chainStep == 0 {
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Scope chain: %v", scopeChainToStrings(scopeChain)))
}
var stepDecision *RoutingDecision
var matchedRule *configstoreTables.TableRoutingRule
var matchedTargetWeight float64
outerLoop:
for _, scope := range scopeChain {
scopeID := scope.ScopeID
rules := re.store.GetScopedRoutingRules(ctx, scope.ScopeName, scopeID)
re.logger.Debug("[RoutingEngine] Evaluating scope=%s, scopeID=%s, ruleCount=%d", scope.ScopeName, scopeID, len(rules))
if len(rules) == 0 {
continue
}
ruleNames := make([]string, 0, len(rules))
for _, r := range rules {
ruleNames = append(ruleNames, r.Name)
}
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Evaluating scope %s: %d rules [%s]", scope.ScopeName, len(rules), strings.Join(ruleNames, ", ")))
for _, rule := range rules {
re.logger.Debug("[RoutingEngine] Evaluating rule: name=%s, expression=%s", rule.Name, rule.CelExpression)
program, err := re.store.GetRoutingProgram(ctx, rule)
if err != nil {
re.logger.Warn("[RoutingEngine] Failed to compile rule %s: %v", rule.Name, err)
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Rule '%s' skipped: compile error: %v", rule.Name, err))
continue
}
matched, err := evaluateCELExpression(program, variables)
if err != nil {
re.logger.Warn("[RoutingEngine] Failed to evaluate rule %s: %v", rule.Name, err)
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Rule '%s' skipped: eval error: %v", rule.Name, err))
continue
}
re.logger.Debug("[RoutingEngine] Rule %s evaluation result: matched=%v", rule.Name, matched)
if !matched {
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Rule '%s' [%s] → no match", rule.Name, rule.CelExpression))
continue
}
target, ok := selectWeightedTarget(rule.Targets)
if !ok {
re.logger.Debug("[RoutingEngine] Rule %s matched but has no valid targets (empty list or all-negative weights), skipping — note: all-zero weights use uniform selection and would not reach here", rule.Name)
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Rule '%s' [%s] → matched but no valid targets (empty or all-negative weights), skipping", rule.Name, rule.CelExpression))
continue
}
provider := string(currentProvider)
if target.Provider != nil && *target.Provider != "" {
provider = *target.Provider
}
model := currentModel
if target.Model != nil && *target.Model != "" {
model = *target.Model
}
keyID := ""
if target.KeyID != nil {
keyID = *target.KeyID
}
stepDecision = &RoutingDecision{
Provider: provider,
Model: model,
KeyID: keyID,
Fallbacks: rule.ParsedFallbacks,
MatchedRuleID: rule.ID,
MatchedRuleName: rule.Name,
}
matchedRule = rule
matchedTargetWeight = target.Weight
break outerLoop
}
}
// TERMINATION 1: No rule matched this iteration.
if stepDecision == nil {
break
}
// Accumulate: last match wins for all fields.
finalDecision = stepDecision
ctx.SetValue(schemas.BifrostContextKeyGovernanceRoutingRuleID, stepDecision.MatchedRuleID)
ctx.SetValue(schemas.BifrostContextKeyGovernanceRoutingRuleName, stepDecision.MatchedRuleName)
chainSuffix := ""
if matchedRule.ChainRule {
chainSuffix = " [chain_rule=true, continuing]"
}
re.logger.Debug("[RoutingEngine] Rule matched! Selected target (weight=%.2f): provider=%s, model=%s, fallbacks=%v%s", matchedTargetWeight, stepDecision.Provider, stepDecision.Model, stepDecision.Fallbacks, chainSuffix)
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Rule '%s' [%s] → matched, selected target (weight=%.2f): provider=%s, model=%s, fallbacks=%v%s", matchedRule.Name, matchedRule.CelExpression, matchedTargetWeight, stepDecision.Provider, stepDecision.Model, stepDecision.Fallbacks, chainSuffix))
// TERMINATION 2: Rule is terminal (chain_rule=false, the default).
if !matchedRule.ChainRule {
break
}
// TERMINATION 3: Cycle detection — if the next state was already visited, continuing would loop forever.
nextState := fmt.Sprintf("%s|%s", stepDecision.Provider, stepDecision.Model)
if _, seen := visited[nextState]; seen {
re.logger.Debug("[RoutingEngine] Chain cycle detected at step=%d (state=%s already visited), stopping", chainStep, nextState)
ctx.AppendRoutingEngineLog(schemas.RoutingEngineRoutingRule, fmt.Sprintf("Chain cycle detected at step %d (provider=%s, model=%s already visited), stopping. Final resolved: provider=%s, model=%s", chainStep, stepDecision.Provider, stepDecision.Model, stepDecision.Provider, stepDecision.Model))
break
}
visited[nextState] = struct{}{}
// Advance context for next chain iteration.
currentProvider = schemas.ModelProvider(stepDecision.Provider)
currentModel = stepDecision.Model
}
if finalDecision == nil {
re.logger.Debug("[RoutingEngine] No routing rule matched, using default routing")
}
return finalDecision, nil
}
// selectWeightedTarget picks one target from the slice using weighted random selection.
// Each target's Weight contributes proportionally to its probability of being chosen.
// Weights do not need to be normalised to 100; the function normalises internally.
// Returns ok=false only when len(targets)==0 or all targets have negative weights (filtered out).
// When all valid targets have weight==0 the function falls back to uniform random selection
// and still returns ok=true, so zero-weight targets are valid and handled.
func selectWeightedTarget(targets []configstoreTables.TableRoutingTarget) (configstoreTables.TableRoutingTarget, bool) {
if len(targets) == 0 {
return configstoreTables.TableRoutingTarget{}, false
}
// Filter out negative weights as a precaution against malformed DB data.
// Negative weights are blocked at write time by validateRoutingTargets, but
// we guard here defensively so a bad row cannot corrupt the cumulative range.
valid := make([]configstoreTables.TableRoutingTarget, 0, len(targets))
for _, t := range targets {
if t.Weight >= 0 {
valid = append(valid, t)
}
}
if len(valid) == 0 {
return configstoreTables.TableRoutingTarget{}, false
}
total := 0.0
for _, t := range valid {
total += t.Weight
}
// All weights are 0 — select uniformly at random among valid targets.
if total == 0 {
return valid[rand.IntN(len(valid))], true
}
if len(valid) == 1 {
return valid[0], true
}
r := rand.Float64() * total
cumulative := 0.0
for _, t := range valid {
cumulative += t.Weight
if r < cumulative {
return t, true
}
}
return valid[len(valid)-1], true
}
// buildScopeChain builds the scope evaluation chain based on organizational hierarchy
// Returns scope levels in precedence order (highest to lowest)
// VirtualKey > Team > Customer > Global
func buildScopeChain(virtualKey *configstoreTables.TableVirtualKey) []ScopeLevel {
var chain []ScopeLevel
// VirtualKey level (highest precedence)
if virtualKey != nil {
chain = append(chain, ScopeLevel{
ScopeName: "virtual_key",
ScopeID: virtualKey.ID,
})
// Team level
if virtualKey.Team != nil {
chain = append(chain, ScopeLevel{
ScopeName: "team",
ScopeID: virtualKey.Team.ID,
})
// Customer level (from Team)
if virtualKey.Team.Customer != nil {
chain = append(chain, ScopeLevel{
ScopeName: "customer",
ScopeID: virtualKey.Team.Customer.ID,
})
}
} else if virtualKey.Customer != nil {
// Customer level (VK attached directly to customer, no Team)
chain = append(chain, ScopeLevel{
ScopeName: "customer",
ScopeID: virtualKey.Customer.ID,
})
}
}
// Global level (lowest precedence)
chain = append(chain, ScopeLevel{
ScopeName: "global",
ScopeID: "",
})
return chain
}
// evaluateCELExpression evaluates a compiled CEL program with given variables
func evaluateCELExpression(program cel.Program, variables map[string]interface{}) (bool, error) {
if program == nil {
return false, fmt.Errorf("CEL program is nil")
}
// Evaluate the program
out, _, err := program.Eval(variables)
if err != nil {
// Gracefully handle "no such key" errors - when a header/param is missing, treat as non-match
if strings.Contains(err.Error(), "no such key") {
return false, nil
}
return false, fmt.Errorf("CEL evaluation error: %w", err)
}
// Convert result to boolean
matched, ok := out.Value().(bool)
if !ok {
return false, fmt.Errorf("CEL expression did not return boolean, got: %T", out.Value())
}
return matched, nil
}
// extractRoutingVariables builds a map of CEL variables from routing context
// This map is used to evaluate CEL expressions in routing rules
func extractRoutingVariables(ctx *RoutingContext) (map[string]interface{}, error) {
if ctx == nil {
return nil, fmt.Errorf("routing context cannot be nil")
}
variables := make(map[string]interface{})
// Basic request context
variables["model"] = ctx.Model
variables["provider"] = string(ctx.Provider)
variables["request_type"] = ctx.RequestType // Normalized request type (e.g., "chat_completion", "embedding")
// Headers and params - normalize headers to lowercase keys for case-insensitive CEL matching
// This allows CEL expressions like headers["content-type"] to work regardless of how the header was sent
normalizedHeaders := make(map[string]string)
if ctx.Headers != nil {
for k, v := range ctx.Headers {
// Store with lowercase key for case-insensitive matching in CEL
normalizedHeaders[strings.ToLower(k)] = v
}
}
variables["headers"] = normalizedHeaders
// Normalize query params to lowercase keys for case-insensitive CEL matching
normalizedParams := make(map[string]string)
if ctx.QueryParams != nil {
for k, v := range ctx.QueryParams {
normalizedParams[strings.ToLower(k)] = v
}
}
variables["params"] = normalizedParams
// Extract VirtualKey context if available
if ctx.VirtualKey != nil {
variables["virtual_key_id"] = ctx.VirtualKey.ID
variables["virtual_key_name"] = ctx.VirtualKey.Name
} else {
variables["virtual_key_id"] = ""
variables["virtual_key_name"] = ""
}
// Extract Team context if available (from VirtualKey)
if ctx.VirtualKey != nil && ctx.VirtualKey.Team != nil {
variables["team_id"] = ctx.VirtualKey.Team.ID
variables["team_name"] = ctx.VirtualKey.Team.Name
} else {
variables["team_id"] = ""
variables["team_name"] = ""
}
// Extract Customer context if available (from Team or directly from VirtualKey)
if ctx.VirtualKey != nil {
if ctx.VirtualKey.Team != nil && ctx.VirtualKey.Team.Customer != nil {
variables["customer_id"] = ctx.VirtualKey.Team.Customer.ID
variables["customer_name"] = ctx.VirtualKey.Team.Customer.Name
} else if ctx.VirtualKey.Customer != nil {
variables["customer_id"] = ctx.VirtualKey.Customer.ID
variables["customer_name"] = ctx.VirtualKey.Customer.Name
} else {
variables["customer_id"] = ""
variables["customer_name"] = ""
}
} else {
variables["customer_id"] = ""
variables["customer_name"] = ""
}
// Populate budget and rate limit variables for current provider/model combination
if ctx.BudgetAndRateLimitStatus != nil {
variables["budget_used"] = ctx.BudgetAndRateLimitStatus.BudgetPercentUsed
variables["tokens_used"] = ctx.BudgetAndRateLimitStatus.RateLimitTokenPercentUsed
variables["request"] = ctx.BudgetAndRateLimitStatus.RateLimitRequestPercentUsed
} else {
// No budget/rate limit configured, provide 0 values
variables["budget_used"] = 0.0
variables["tokens_used"] = 0.0
variables["request"] = 0.0
}
return variables, nil
}
// scopeChainToStrings converts a scope chain to a string representation for logging
func scopeChainToStrings(chain []ScopeLevel) []string {
scopes := make([]string, 0, len(chain))
for _, scope := range chain {
if scope.ScopeID == "" {
scopes = append(scopes, scope.ScopeName)
} else {
scopes = append(scopes, fmt.Sprintf("%s(%s)", scope.ScopeName, scope.ScopeID))
}
}
return scopes
}
// createCELEnvironment creates a new CEL environment for routing rules
func createCELEnvironment() (*cel.Env, error) {
return cel.NewEnv(
// Basic request context
cel.Variable("model", cel.StringType),
cel.Variable("provider", cel.StringType),
cel.Variable("request_type", cel.StringType), // Normalized request type (e.g., "chat_completion", "embedding", "text_completion")
// Headers and params (dynamic from request)
cel.Variable("headers", cel.MapType(cel.StringType, cel.StringType)),
cel.Variable("params", cel.MapType(cel.StringType, cel.StringType)),
// VirtualKey/Team/Customer context
cel.Variable("virtual_key_id", cel.StringType),
cel.Variable("virtual_key_name", cel.StringType),
cel.Variable("team_id", cel.StringType),
cel.Variable("team_name", cel.StringType),
cel.Variable("customer_id", cel.StringType),
cel.Variable("customer_name", cel.StringType),
// Rate limit & budget status (real-time capacity metrics as percentages)
cel.Variable("tokens_used", cel.DoubleType),
cel.Variable("request", cel.DoubleType),
cel.Variable("budget_used", cel.DoubleType),
)
}

File diff suppressed because it is too large Load Diff

3391
plugins/governance/store.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,124 @@
package governance
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newStandaloneStore builds a LocalGovernanceStore with no config store /
// persistence — just the in-memory maps. Enough for exercising the CAS
// primitives without going through GovernanceConfig preload paths.
func newStandaloneStore(t *testing.T) *LocalGovernanceStore {
t.Helper()
return &LocalGovernanceStore{
logger: NewMockLogger(),
LastDBUsagesBudgets: map[string]float64{},
LastDBUsagesTokensRateLimits: map[string]int64{},
LastDBUsagesRequestsRateLimits: map[string]int64{},
}
}
// TestBumpBudgetUsage_NoLostIncrements proves the CAS retry loop in
// BumpBudgetUsage never drops a concurrent increment. Without the CAS, the
// Load→clone→mutate→Store sequence races and the final CurrentUsage ends up
// strictly less than N*cost under contention.
func TestBumpBudgetUsage_NoLostIncrements(t *testing.T) {
store := newStandaloneStore(t)
budgetID := "concurrent-budget"
store.budgets.Store(budgetID, buildBudget(budgetID, 1_000_000_000, "24h"))
const goroutines = 256
const perGoroutine = 50
const cost = 1.0
expected := float64(goroutines * perGoroutine)
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < perGoroutine; j++ {
assert.NoError(t, store.BumpBudgetUsage(context.Background(), budgetID, cost))
}
}()
}
wg.Wait()
final := store.LoadBudget(context.Background(), budgetID)
require.NotNil(t, final)
assert.Equal(t, expected, final.CurrentUsage, "CurrentUsage must equal total increments — any shortfall is a dropped write")
}
// TestBumpRateLimitUsage_NoLostIncrements covers the rate-limit variant of
// the same race: token and request counters are independent int64 fields
// updated on the same struct, and both must survive contention intact.
func TestBumpRateLimitUsage_NoLostIncrements(t *testing.T) {
store := newStandaloneStore(t)
rlID := "concurrent-rate-limit"
store.rateLimits.Store(rlID, buildRateLimit(rlID, 1_000_000_000, 1_000_000_000))
const goroutines = 256
const perGoroutine = 50
const tokensPerCall = int64(7)
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
for j := 0; j < perGoroutine; j++ {
assert.NoError(t, store.BumpRateLimitUsage(context.Background(), rlID, tokensPerCall, true, true))
}
}()
}
wg.Wait()
final := store.LoadRateLimit(context.Background(), rlID)
require.NotNil(t, final)
assert.Equal(t, int64(goroutines*perGoroutine)*tokensPerCall, final.TokenCurrentUsage, "TokenCurrentUsage dropped increments")
assert.Equal(t, int64(goroutines*perGoroutine), final.RequestCurrentUsage, "RequestCurrentUsage dropped increments")
}
// TestResetBudgetAt_ConcurrentResettersCollapse confirms that many goroutines
// all trying to reset the same budget to the same newLastReset deduplicate
// cleanly via CAS — exactly one resetter observes the transition, everyone
// else gets (nil, false). Without the re-check inside ResetBudgetAt, each
// goroutine would re-zero the counter and drop any increments applied in
// between.
func TestResetBudgetAt_ConcurrentResettersCollapse(t *testing.T) {
store := newStandaloneStore(t)
budgetID := "reset-collapse"
old := buildBudget(budgetID, 1000, "1h")
old.LastReset = time.Now().Add(-2 * time.Hour)
old.CurrentUsage = 999
store.budgets.Store(budgetID, old)
const goroutines = 128
newLastReset := time.Now()
var successes atomic.Int64
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
if _, ok := store.ResetBudgetAt(context.Background(), budgetID, newLastReset); ok {
successes.Add(1)
}
}()
}
wg.Wait()
assert.Equal(t, int64(1), successes.Load(), "exactly one resetter should win the CAS when all target the same newLastReset")
final := store.LoadBudget(context.Background(), budgetID)
require.NotNil(t, final)
assert.Equal(t, 0.0, final.CurrentUsage)
assert.True(t, final.LastReset.Equal(newLastReset))
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,350 @@
package governance
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/maximhq/bifrost/framework/modelcatalog"
"github.com/stretchr/testify/assert"
)
// MockLogger implements schemas.Logger for testing
type MockLogger struct {
mu sync.Mutex
logs []string
errors []string
debugs []string
infos []string
warnings []string
}
func NewMockLogger() *MockLogger {
return &MockLogger{
logs: make([]string, 0),
errors: make([]string, 0),
debugs: make([]string, 0),
infos: make([]string, 0),
warnings: make([]string, 0),
}
}
func (ml *MockLogger) SetLevel(level schemas.LogLevel) {}
func (ml *MockLogger) SetOutputType(outputType schemas.LoggerOutputType) {}
func (ml *MockLogger) Error(format string, args ...interface{}) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.errors = append(ml.errors, format)
}
func (ml *MockLogger) Warn(format string, args ...interface{}) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.warnings = append(ml.warnings, format)
}
func (ml *MockLogger) Info(format string, args ...interface{}) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.infos = append(ml.infos, format)
}
func (ml *MockLogger) Debug(format string, args ...interface{}) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.debugs = append(ml.debugs, format)
}
func (ml *MockLogger) Fatal(format string, args ...interface{}) {
ml.mu.Lock()
defer ml.mu.Unlock()
ml.errors = append(ml.errors, format)
}
func (ml *MockLogger) LogHTTPRequest(level schemas.LogLevel, msg string) schemas.LogEventBuilder {
return schemas.NoopLogEvent
}
// Test data builders
func buildVirtualKey(id, value, name string, isActive bool) *configstoreTables.TableVirtualKey {
return &configstoreTables.TableVirtualKey{
ID: id,
Value: value,
Name: name,
IsActive: isActive,
}
}
func buildVirtualKeyWithBudget(id, value, name string, budget *configstoreTables.TableBudget) *configstoreTables.TableVirtualKey {
vk := buildVirtualKey(id, value, name, true)
vkID := id
budget.VirtualKeyID = &vkID
vk.Budgets = []configstoreTables.TableBudget{*budget}
// Add a default provider config so the resolver doesn't block at provider check
vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"*"}),
}
return vk
}
func buildVirtualKeyWithRateLimit(id, value, name string, rateLimit *configstoreTables.TableRateLimit) *configstoreTables.TableVirtualKey {
vk := buildVirtualKey(id, value, name, true)
vk.RateLimit = rateLimit
rateLimitID := rateLimit.ID
vk.RateLimitID = &rateLimitID
// Add a default provider config so the resolver doesn't block at provider check
vk.ProviderConfigs = []configstoreTables.TableVirtualKeyProviderConfig{
buildProviderConfig("openai", []string{"*"}),
}
return vk
}
func buildVirtualKeyWithProviders(id, value, name string, providers []configstoreTables.TableVirtualKeyProviderConfig) *configstoreTables.TableVirtualKey {
vk := buildVirtualKey(id, value, name, true)
vk.ProviderConfigs = providers
return vk
}
func buildBudget(id string, maxLimit float64, resetDuration string) *configstoreTables.TableBudget {
return &configstoreTables.TableBudget{
ID: id,
MaxLimit: maxLimit,
CurrentUsage: 0,
ResetDuration: resetDuration,
LastReset: time.Now(),
}
}
func buildBudgetWithUsage(id string, maxLimit, currentUsage float64, resetDuration string) *configstoreTables.TableBudget {
return &configstoreTables.TableBudget{
ID: id,
MaxLimit: maxLimit,
CurrentUsage: currentUsage,
ResetDuration: resetDuration,
LastReset: time.Now(),
}
}
func buildRateLimit(id string, tokenMaxLimit, requestMaxLimit int64) *configstoreTables.TableRateLimit {
duration := "1m"
return &configstoreTables.TableRateLimit{
ID: id,
TokenMaxLimit: &tokenMaxLimit,
TokenCurrentUsage: 0,
TokenResetDuration: &duration,
TokenLastReset: time.Now(),
RequestMaxLimit: &requestMaxLimit,
RequestCurrentUsage: 0,
RequestResetDuration: &duration,
RequestLastReset: time.Now(),
}
}
func buildRateLimitWithUsage(id string, tokenMaxLimit, tokenUsage, requestMaxLimit, requestUsage int64) *configstoreTables.TableRateLimit {
duration := "1m"
return &configstoreTables.TableRateLimit{
ID: id,
TokenMaxLimit: &tokenMaxLimit,
TokenCurrentUsage: tokenUsage,
TokenResetDuration: &duration,
TokenLastReset: time.Now(),
RequestMaxLimit: &requestMaxLimit,
RequestCurrentUsage: requestUsage,
RequestResetDuration: &duration,
RequestLastReset: time.Now(),
}
}
func buildTeam(id, name string, budget *configstoreTables.TableBudget) *configstoreTables.TableTeam {
team := &configstoreTables.TableTeam{
ID: id,
Name: name,
}
if budget != nil {
budget.TeamID = &team.ID
team.Budgets = []configstoreTables.TableBudget{*budget}
}
return team
}
func buildCustomer(id, name string, budget *configstoreTables.TableBudget) *configstoreTables.TableCustomer {
customer := &configstoreTables.TableCustomer{
ID: id,
Name: name,
}
if budget != nil {
customer.Budget = budget
customer.BudgetID = &budget.ID
}
return customer
}
func buildProviderConfig(provider string, allowedModels []string) configstoreTables.TableVirtualKeyProviderConfig {
return configstoreTables.TableVirtualKeyProviderConfig{
Provider: provider,
AllowedModels: allowedModels,
Weight: bifrost.Ptr(1.0),
RateLimit: nil,
Keys: []configstoreTables.TableKey{},
}
}
func buildProviderConfigWithBudgets(provider string, allowedModels []string, budgets []configstoreTables.TableBudget) configstoreTables.TableVirtualKeyProviderConfig {
pc := buildProviderConfig(provider, allowedModels)
pc.Budgets = budgets
return pc
}
func buildVirtualKeyWithMultiBudgets(id, value, name string, budgets []configstoreTables.TableBudget) *configstoreTables.TableVirtualKey {
vk := buildVirtualKey(id, value, name, true)
for i := range budgets {
vkID := id
budgets[i].VirtualKeyID = &vkID
}
vk.Budgets = budgets
return vk
}
func buildProviderConfigWithRateLimit(provider string, allowedModels []string, rateLimit *configstoreTables.TableRateLimit) configstoreTables.TableVirtualKeyProviderConfig {
pc := buildProviderConfig(provider, allowedModels)
pc.RateLimit = rateLimit
if rateLimit != nil {
pc.RateLimitID = &rateLimit.ID
}
return pc
}
// Test helpers
func assertDecision(t *testing.T, expected Decision, result *EvaluationResult) {
t.Helper()
assert.NotNil(t, result, "EvaluationResult should not be nil")
assert.Equal(t, expected, result.Decision, "Decision mismatch. Reason: %s", result.Reason)
}
func assertVirtualKeyFound(t *testing.T, result *EvaluationResult) {
t.Helper()
assert.NotNil(t, result.VirtualKey, "VirtualKey should be found in result")
}
func assertRateLimitInfo(t *testing.T, result *EvaluationResult) {
t.Helper()
assert.NotNil(t, result.RateLimitInfo, "RateLimitInfo should be present in result")
}
func buildModelConfig(id, modelName string, provider *string, budget *configstoreTables.TableBudget, rateLimit *configstoreTables.TableRateLimit) *configstoreTables.TableModelConfig {
mc := &configstoreTables.TableModelConfig{
ID: id,
ModelName: modelName,
Provider: provider,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if budget != nil {
mc.Budget = budget
mc.BudgetID = &budget.ID
}
if rateLimit != nil {
mc.RateLimit = rateLimit
mc.RateLimitID = &rateLimit.ID
}
return mc
}
func buildProviderWithGovernance(name string, budget *configstoreTables.TableBudget, rateLimit *configstoreTables.TableRateLimit) *configstoreTables.TableProvider {
provider := &configstoreTables.TableProvider{
Name: name,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if budget != nil {
provider.Budget = budget
provider.BudgetID = &budget.ID
}
if rateLimit != nil {
provider.RateLimit = rateLimit
provider.RateLimitID = &rateLimit.ID
}
return provider
}
func boolPtr(b bool) *bool {
return &b
}
// Datasheet is fetched once per test binary run via sync.Once.
var (
datasheetOnce sync.Once
datasheetBaseIndex map[string]string
datasheetErr error
)
// fetchDatasheetBaseIndex downloads the default datasheet and builds a
// model → base_model index, mirroring ModelCatalog.populateModelPoolFromPricingData.
func fetchDatasheetBaseIndex() {
client := &http.Client{Timeout: modelcatalog.DefaultPricingTimeout}
resp, err := client.Get(modelcatalog.DefaultPricingURL)
if err != nil {
datasheetErr = err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
datasheetErr = fmt.Errorf("datasheet HTTP %d", resp.StatusCode)
return
}
data, err := io.ReadAll(resp.Body)
if err != nil {
datasheetErr = err
return
}
var entries map[string]modelcatalog.PricingEntry
if err := json.Unmarshal(data, &entries); err != nil {
datasheetErr = err
return
}
index := make(map[string]string, len(entries))
for modelKey, entry := range entries {
if entry.BaseModel == "" {
continue
}
// Strip provider prefix (same as convertPricingDataToTableModelPricing)
modelName := modelKey
if strings.Contains(modelKey, "/") {
parts := strings.Split(modelKey, "/")
if len(parts) > 1 {
modelName = strings.Join(parts[1:], "/")
}
}
index[modelName] = entry.BaseModel
}
datasheetBaseIndex = index
}
// newTestModelCatalog creates a test ModelCatalog using the fetched datasheet base model index.
// This provides proper nil-pointer semantics (unlike an interface wrapper).
func newTestModelCatalog(t *testing.T) *modelcatalog.ModelCatalog {
t.Helper()
datasheetOnce.Do(fetchDatasheetBaseIndex)
if datasheetErr != nil {
t.Skipf("skipping: failed to fetch datasheet for test model catalog: %v", datasheetErr)
}
return modelcatalog.NewTestCatalog(datasheetBaseIndex)
}

View File

@@ -0,0 +1,320 @@
// Package governance provides simplified usage tracking for the new hierarchical system
package governance
import (
"context"
"fmt"
"sync"
"time"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"gorm.io/gorm"
)
// UsageUpdate contains data for VK-level usage tracking
type UsageUpdate struct {
VirtualKey string `json:"virtual_key"`
Provider schemas.ModelProvider `json:"provider"`
Model string `json:"model"`
Success bool `json:"success"`
TokensUsed int64 `json:"tokens_used"`
Cost float64 `json:"cost"` // Cost in dollars
RequestID string `json:"request_id"`
UserID string `json:"user_id,omitempty"` // User ID for enterprise user-level governance
// Streaming optimization fields
IsStreaming bool `json:"is_streaming"` // Whether this is a streaming response
IsFinalChunk bool `json:"is_final_chunk"` // Whether this is the final chunk
HasUsageData bool `json:"has_usage_data"` // Whether this chunk contains usage data
}
// UsageTracker manages VK-level usage tracking and budget management
type UsageTracker struct {
store GovernanceStore
resolver *BudgetResolver
configStore configstore.ConfigStore
logger schemas.Logger
// Background workers
trackerCtx context.Context
trackerCancel context.CancelFunc
resetTicker *time.Ticker
done chan struct{}
wg sync.WaitGroup
}
const (
workerInterval = 10 * time.Second
)
// NewUsageTracker creates a new usage tracker for the hierarchical budget system
func NewUsageTracker(ctx context.Context, store GovernanceStore, resolver *BudgetResolver, configStore configstore.ConfigStore, logger schemas.Logger) *UsageTracker {
tracker := &UsageTracker{
store: store,
resolver: resolver,
configStore: configStore,
logger: logger,
done: make(chan struct{}),
}
// Start background workers for business logic
tracker.trackerCtx, tracker.trackerCancel = context.WithCancel(context.Background())
tracker.startWorkers(tracker.trackerCtx)
return tracker
}
// UpdateUsage queues a usage update for async processing (main business entry point)
func (t *UsageTracker) UpdateUsage(ctx context.Context, update *UsageUpdate) {
// Only process successful requests for usage tracking
if !update.Success {
t.logger.Debug("Request was not successful, skipping usage update")
return
}
// Streaming optimization: only process certain updates based on streaming status
shouldUpdateTokens := !update.IsStreaming || (update.IsStreaming && update.HasUsageData)
shouldUpdateRequests := !update.IsStreaming || (update.IsStreaming && update.IsFinalChunk)
shouldUpdateBudget := !update.IsStreaming || (update.IsStreaming && update.HasUsageData)
// 1. Update rate limit usage for both provider-level and model-level
// This applies even when virtual keys are disabled or not present
// Guard: only update when both Provider and Model are set (MCP paths may not have these)
if update.Provider != "" && update.Model != "" {
if err := t.store.UpdateProviderAndModelRateLimitUsageInMemory(ctx, update.Model, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil {
t.logger.Error("failed to update rate limit usage for model %s, provider %s: %v", update.Model, update.Provider, err)
}
}
// 2. Update budget usage for both provider-level and model-level
// This applies even when virtual keys are disabled or not present
// Guard: only update when both Provider and Model are set (MCP paths may not have these)
if update.Provider != "" && update.Model != "" && shouldUpdateBudget && update.Cost > 0 {
if err := t.store.UpdateProviderAndModelBudgetUsageInMemory(ctx, update.Model, update.Provider, update.Cost); err != nil {
t.logger.Error("failed to update budget usage for model %s, provider %s: %v", update.Model, update.Provider, err)
}
}
// 3. Update user-level governance (enterprise-only, before VK-level)
if update.UserID != "" {
// Update user rate limit usage
if err := t.store.UpdateUserRateLimitUsageInMemory(ctx, update.UserID, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil {
t.logger.Error("failed to update user rate limit usage for user %s: %v", update.UserID, err)
}
// Update user budget usage
if shouldUpdateBudget && update.Cost > 0 {
if err := t.store.UpdateUserBudgetUsageInMemory(ctx, update.UserID, update.Cost); err != nil {
t.logger.Error("failed to update user budget usage for user %s: %v", update.UserID, err)
}
}
}
// 4. Now handle virtual key-level updates (if virtual key exists)
if update.VirtualKey == "" {
// No virtual key, provider-level and model-level updates already done above
return
}
// Get virtual key
vk, exists := t.store.GetVirtualKey(ctx, update.VirtualKey)
if !exists {
t.logger.Debug(fmt.Sprintf("Virtual key not found: %s", update.VirtualKey))
return
}
// Update rate limit usage (VK-level, provider-config-level, team-level, customer-level) if applicable
// Include TeamID and CustomerID checks since rate limits can be configured at those levels
if vk.RateLimit != nil || len(vk.ProviderConfigs) > 0 || vk.TeamID != nil || vk.CustomerID != nil {
if err := t.store.UpdateVirtualKeyRateLimitUsageInMemory(ctx, vk, update.Provider, update.TokensUsed, shouldUpdateTokens, shouldUpdateRequests); err != nil {
t.logger.Error("failed to update rate limit usage for VK %s: %v", vk.ID, err)
}
}
// Update budget usage in hierarchy (VK → Team → Customer) only if we have usage data
if shouldUpdateBudget && update.Cost > 0 {
t.logger.Debug("updating budget usage for VK %s", vk.ID)
// Use atomic budget update to prevent race conditions and ensure consistency
if err := t.store.UpdateVirtualKeyBudgetUsageInMemory(ctx, vk, update.Provider, update.Cost); err != nil {
t.logger.Error("failed to update budget hierarchy atomically for VK %s: %v", vk.ID, err)
}
}
}
// startWorkers starts all background workers for business logic
func (t *UsageTracker) startWorkers(ctx context.Context) {
// Counter reset manager (business logic)
t.resetTicker = time.NewTicker(workerInterval)
t.wg.Add(1)
go t.resetWorker(ctx)
}
// resetWorker manages periodic resets of rate limit and usage counters
func (t *UsageTracker) resetWorker(ctx context.Context) {
defer t.wg.Done()
for {
select {
case <-t.resetTicker.C:
t.resetExpiredCounters(ctx)
case <-t.done:
return
}
}
}
// resetExpiredCounters manages periodic resets of usage counters AND budgets using flexible durations
func (t *UsageTracker) resetExpiredCounters(ctx context.Context) {
// ==== PART 1: Reset Rate Limits ====
resetRateLimits := t.store.ResetExpiredRateLimitsInMemory(ctx)
if err := t.store.ResetExpiredRateLimits(ctx, resetRateLimits); err != nil {
t.logger.Error("failed to reset expired rate limits: %v", err)
}
// ==== PART 2: Reset Budgets ====
resetBudgets := t.store.ResetExpiredBudgetsInMemory(ctx)
if err := t.store.ResetExpiredBudgets(ctx, resetBudgets); err != nil {
t.logger.Error("failed to reset expired budgets: %v", err)
}
// ==== PART 3: Dump all rate limits and budgets to database ====
if err := t.store.DumpRateLimits(ctx, nil, nil); err != nil {
t.logger.Error("failed to dump rate limits to database: %v", err)
}
if err := t.store.DumpBudgets(ctx, nil); err != nil {
t.logger.Error("failed to dump budgets to database: %v", err)
}
}
// Public methods for monitoring and admin operations
// PerformStartupResets checks and resets any expired rate limits and budgets on startup
func (t *UsageTracker) PerformStartupResets(ctx context.Context) error {
if t.configStore == nil {
t.logger.Warn("config store is not available, skipping initialization of usage tracker")
return nil
}
t.logger.Debug("performing startup reset check for expired rate limits and budgets")
now := time.Now()
var resetRateLimits []*configstoreTables.TableRateLimit
var errs []string
var vksWithRateLimits int
var vksWithoutRateLimits int
// ==== RESET EXPIRED RATE LIMITS ====
// Check ALL virtual keys (both active and inactive) for expired rate limits
allVKs, err := t.configStore.GetVirtualKeys(ctx)
if err != nil {
errs = append(errs, fmt.Sprintf("failed to load virtual keys for reset: %s", err.Error()))
} else {
t.logger.Debug(fmt.Sprintf("startup reset: checking %d virtual keys (active + inactive) for expired rate limits", len(allVKs)))
}
for i := range allVKs {
vk := &allVKs[i] // Get pointer to VK for modifications
if vk.RateLimit == nil {
vksWithoutRateLimits++
continue
}
vksWithRateLimits++
rateLimit := vk.RateLimit
rateLimitUpdated := false
// Check token limits
if rateLimit.TokenResetDuration != nil {
if duration, err := configstoreTables.ParseDuration(*rateLimit.TokenResetDuration); err == nil {
timeSinceReset := now.Sub(rateLimit.TokenLastReset)
if timeSinceReset >= duration {
rateLimit.TokenCurrentUsage = 0
rateLimit.TokenLastReset = now
rateLimitUpdated = true
}
} else {
errs = append(errs, fmt.Sprintf("invalid token reset duration for VK %s: %s", vk.ID, *rateLimit.TokenResetDuration))
}
}
// Check request limits
if rateLimit.RequestResetDuration != nil {
if duration, err := configstoreTables.ParseDuration(*rateLimit.RequestResetDuration); err == nil {
timeSinceReset := now.Sub(rateLimit.RequestLastReset)
if timeSinceReset >= duration {
rateLimit.RequestCurrentUsage = 0
rateLimit.RequestLastReset = now
rateLimitUpdated = true
}
} else {
errs = append(errs, fmt.Sprintf("invalid request reset duration for VK %s: %s", vk.ID, *rateLimit.RequestResetDuration))
}
}
if rateLimitUpdated {
resetRateLimits = append(resetRateLimits, rateLimit)
}
}
// DB reset is also handled by this function
resetBudgets := t.store.ResetExpiredBudgetsInMemory(ctx)
if err := t.store.ResetExpiredBudgets(ctx, resetBudgets); err != nil {
errs = append(errs, fmt.Sprintf("failed to reset expired budgets: %s", err.Error()))
}
// ==== PERSIST RESETS TO DATABASE ====
// Use selective updates to avoid overwriting config fields (max_limit, reset_duration)
if t.configStore != nil && len(resetRateLimits) > 0 {
if err := t.configStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error {
for _, rateLimit := range resetRateLimits {
// Build update map with only the fields that were reset
updates := make(map[string]interface{})
updates["token_current_usage"] = rateLimit.TokenCurrentUsage
updates["token_last_reset"] = rateLimit.TokenLastReset
updates["request_current_usage"] = rateLimit.RequestCurrentUsage
updates["request_last_reset"] = rateLimit.RequestLastReset
// Direct UPDATE only resets usage and last_reset fields
// This prevents overwriting max_limit or reset_duration that may have been changed during startup
result := tx.WithContext(ctx).
Session(&gorm.Session{SkipHooks: true}).
Model(&configstoreTables.TableRateLimit{}).
Where("id = ?", rateLimit.ID).
Updates(updates)
if result.Error != nil {
return fmt.Errorf("failed to reset rate limit %s: %w", rateLimit.ID, result.Error)
}
}
return nil
}); err != nil {
errs = append(errs, fmt.Sprintf("failed to persist rate limit resets: %s", err.Error()))
}
}
if len(errs) > 0 {
t.logger.Error("startup reset encountered %d errors: %v", len(errs), errs)
return fmt.Errorf("startup reset completed with %d errors", len(errs))
}
return nil
}
// Cleanup stops all background workers and flushes pending operations
func (t *UsageTracker) Cleanup() error {
// Stop background workers
if t.trackerCancel != nil {
t.trackerCancel()
}
close(t.done)
if t.resetTicker != nil {
t.resetTicker.Stop()
}
// Wait for workers to finish
t.wg.Wait()
t.logger.Debug("usage tracker cleanup completed")
return nil
}

View File

@@ -0,0 +1,166 @@
package governance
import (
"context"
"testing"
"time"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestUsageTracker_UpdateUsage_FailedRequest tests usage tracking for a failed request
func TestUsageTracker_UpdateUsage_FailedRequest(t *testing.T) {
logger := NewMockLogger()
budget := buildBudgetWithUsage("budget1", 1000.0, 0.0, "1d")
vk := buildVirtualKeyWithBudget("vk1", "sk-bf-test", "Test VK", budget)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
Budgets: []configstoreTables.TableBudget{*budget},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
tracker := NewUsageTracker(context.Background(), store, resolver, nil, logger)
defer tracker.Cleanup()
update := &UsageUpdate{
VirtualKey: "sk-bf-test",
Provider: schemas.OpenAI,
Model: "gpt-4",
Success: false, // Failed request
TokensUsed: 100,
Cost: 25.5,
RequestID: "req-123",
}
tracker.UpdateUsage(context.Background(), update)
// Give time for async processing
time.Sleep(200 * time.Millisecond)
// Verify budget was NOT updated - retrieve from store
budgets := store.GetGovernanceData(context.Background()).Budgets
updatedBudget, exists := budgets["budget1"]
require.True(t, exists)
require.NotNil(t, updatedBudget)
assert.Equal(t, 0.0, updatedBudget.CurrentUsage, "Failed request should not update budget")
}
// TestUsageTracker_UpdateUsage_VirtualKeyNotFound tests handling of missing VK
func TestUsageTracker_UpdateUsage_VirtualKeyNotFound(t *testing.T) {
logger := NewMockLogger()
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
tracker := NewUsageTracker(context.Background(), store, resolver, nil, logger)
defer tracker.Cleanup()
update := &UsageUpdate{
VirtualKey: "sk-bf-nonexistent",
Provider: schemas.OpenAI,
Model: "gpt-4",
Success: true,
TokensUsed: 100,
Cost: 25.5,
}
// Should not panic or error
tracker.UpdateUsage(context.Background(), update)
time.Sleep(100 * time.Millisecond)
// Just verify it doesn't crash
assert.True(t, true)
}
// TestUsageTracker_UpdateUsage_StreamingOptimization tests streaming request handling
func TestUsageTracker_UpdateUsage_StreamingOptimization(t *testing.T) {
logger := NewMockLogger()
rateLimit := buildRateLimitWithUsage("rl1", 10000, 0, 1000, 0)
vk := buildVirtualKeyWithRateLimit("vk1", "sk-bf-test", "Test VK", rateLimit)
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{
VirtualKeys: []configstoreTables.TableVirtualKey{*vk},
RateLimits: []configstoreTables.TableRateLimit{*rateLimit},
}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
tracker := NewUsageTracker(context.Background(), store, resolver, nil, logger)
defer tracker.Cleanup()
// First streaming chunk (not final, has usage data)
update1 := &UsageUpdate{
VirtualKey: "sk-bf-test",
Provider: schemas.OpenAI,
Model: "gpt-4",
Success: true,
TokensUsed: 50,
Cost: 0.0, // No cost on non-final chunks
RequestID: "req-123",
IsStreaming: true,
IsFinalChunk: false,
HasUsageData: true,
}
tracker.UpdateUsage(context.Background(), update1)
time.Sleep(200 * time.Millisecond)
// Retrieve the updated rate limit from the main RateLimits map
governanceData := store.GetGovernanceData(context.Background())
updatedRateLimit, exists := governanceData.RateLimits["rl1"]
require.True(t, exists, "Rate limit should exist")
require.NotNil(t, updatedRateLimit)
// Tokens should be updated but not requests (not final chunk)
assert.Equal(t, int64(50), updatedRateLimit.TokenCurrentUsage, "Tokens should be updated on non-final chunk")
// Final chunk
update2 := &UsageUpdate{
VirtualKey: "sk-bf-test",
Provider: schemas.OpenAI,
Model: "gpt-4",
Success: true,
TokensUsed: 0, // Already counted
Cost: 12.5,
RequestID: "req-123",
IsStreaming: true,
IsFinalChunk: true,
HasUsageData: true,
}
tracker.UpdateUsage(context.Background(), update2)
time.Sleep(200 * time.Millisecond)
// Retrieve the updated rate limit again
governanceData = store.GetGovernanceData(context.Background())
updatedRateLimit, exists = governanceData.RateLimits["rl1"]
require.True(t, exists, "Rate limit should exist")
require.NotNil(t, updatedRateLimit)
// Request counter should be updated on final chunk
assert.Equal(t, int64(1), updatedRateLimit.RequestCurrentUsage, "Request should be incremented on final chunk")
}
// TestUsageTracker_Cleanup tests cleanup of the usage tracker
func TestUsageTracker_Cleanup(t *testing.T) {
logger := NewMockLogger()
store, err := NewLocalGovernanceStore(context.Background(), logger, nil, &configstore.GovernanceConfig{}, nil)
require.NoError(t, err)
resolver := NewBudgetResolver(store, nil, logger, nil)
tracker := NewUsageTracker(context.Background(), store, resolver, nil, logger)
// Should cleanup without error
err = tracker.Cleanup()
assert.NoError(t, err, "Cleanup should succeed")
}

143
plugins/governance/utils.go Normal file
View File

@@ -0,0 +1,143 @@
// Package governance provides utility functions for the governance plugin
package governance
import (
"context"
"strings"
bifrost "github.com/maximhq/bifrost/core"
"github.com/maximhq/bifrost/core/schemas"
"github.com/valyala/fasthttp"
)
// ParseVirtualKeyFromFastHTTPRequest parses the virtual key from FastHTTP request headers.
// Parameters:
// - req: The FastHTTP request containing headers to parse
//
// Returns:
// - *string: The virtual key if found, nil otherwise
func ParseVirtualKeyFromFastHTTPRequest(req *fasthttp.RequestCtx) *string {
vkHeader := string(req.Request.Header.Peek("x-bf-vk"))
if vkHeader != "" && strings.HasPrefix(strings.ToLower(vkHeader), VirtualKeyPrefix) {
return bifrost.Ptr(vkHeader)
}
authHeader := string(req.Request.Header.Peek("Authorization"))
if authHeader != "" {
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
authHeaderValue := strings.TrimSpace(authHeader[7:]) // Remove "Bearer " prefix
if authHeaderValue != "" && strings.HasPrefix(strings.ToLower(authHeaderValue), VirtualKeyPrefix) {
return bifrost.Ptr(authHeaderValue)
}
}
}
xAPIKey := string(req.Request.Header.Peek("x-api-key"))
if xAPIKey != "" && strings.HasPrefix(strings.ToLower(xAPIKey), VirtualKeyPrefix) {
return bifrost.Ptr(xAPIKey)
}
xGoogleAPIKey := string(req.Request.Header.Peek("x-goog-api-key"))
if xGoogleAPIKey != "" && strings.HasPrefix(strings.ToLower(xGoogleAPIKey), VirtualKeyPrefix) {
return bifrost.Ptr(xGoogleAPIKey)
}
return nil
}
// parseVirtualKeyFromHTTPRequest parses the virtual key from HTTP request headers.
// It checks multiple headers in order: x-bf-vk, Authorization (Bearer token), x-api-key, and x-goog-api-key.
// Parameters:
// - req: The HTTP request containing headers to parse
//
// Returns:
// - *string: The virtual key if found, nil otherwise
func parseVirtualKeyFromHTTPRequest(req *schemas.HTTPRequest) *string {
var virtualKeyValue string
vkHeader := req.CaseInsensitiveHeaderLookup("x-bf-vk")
if vkHeader != "" && strings.HasPrefix(strings.ToLower(vkHeader), VirtualKeyPrefix) {
return bifrost.Ptr(vkHeader)
}
authHeader := req.CaseInsensitiveHeaderLookup("Authorization")
if authHeader != "" {
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
authHeaderValue := strings.TrimSpace(authHeader[7:]) // Remove "Bearer " prefix
if authHeaderValue != "" && strings.HasPrefix(strings.ToLower(authHeaderValue), VirtualKeyPrefix) {
virtualKeyValue = authHeaderValue
}
}
}
if virtualKeyValue != "" {
return bifrost.Ptr(virtualKeyValue)
}
xAPIKey := req.CaseInsensitiveHeaderLookup("x-api-key")
if xAPIKey != "" && strings.HasPrefix(strings.ToLower(xAPIKey), VirtualKeyPrefix) {
return bifrost.Ptr(xAPIKey)
}
// Checking x-goog-api-key header
xGoogleAPIKey := req.CaseInsensitiveHeaderLookup("x-goog-api-key")
if xGoogleAPIKey != "" && strings.HasPrefix(strings.ToLower(xGoogleAPIKey), VirtualKeyPrefix) {
return bifrost.Ptr(xGoogleAPIKey)
}
return nil
}
// getWeight safely dereferences a *float64 weight pointer, returning 1.0 as default if nil.
// This allows distinguishing between "not set" (nil -> 1.0) and "explicitly set to 0" (0.0).
func getWeight(w *float64) float64 {
if w == nil {
return 1.0
}
return *w
}
// filterModelsForVirtualKey filters models based on virtual key's provider configs
// Returns only models that are allowed by the virtual key's ProviderConfigs
func (p *GovernancePlugin) filterModelsForVirtualKey(
ctx context.Context,
models []schemas.Model,
virtualKeyValue string,
) []schemas.Model {
// Get virtual key configuration
vk, exists := p.store.GetVirtualKey(ctx, virtualKeyValue)
if !exists {
p.logger.Warn("[Governance] Virtual key not found for list models filtering: %s", virtualKeyValue)
return []schemas.Model{} // VK not found, return empty list
}
// Empty ProviderConfigs means no models are allowed (deny-by-default)
if len(vk.ProviderConfigs) == 0 {
return []schemas.Model{}
}
// Filter models based on ProviderConfigs
filteredModels := make([]schemas.Model, 0, len(models))
for _, model := range models {
provider, modelName := schemas.ParseModelString(model.ID, "")
// Check if this provider/model combination is allowed
isAllowed := false
for _, pc := range vk.ProviderConfigs {
if pc.Provider == string(provider) {
if p.modelCatalog != nil && p.inMemoryStore != nil {
providerConfig, ok := p.inMemoryStore.GetConfiguredProviders()[provider]
providerConfigPtr := &providerConfig
if !ok {
providerConfigPtr = nil
}
if p.modelCatalog.IsModelAllowedForProvider(provider, modelName, providerConfigPtr, pc.AllowedModels) {
isAllowed = true
break
}
} else {
if pc.AllowedModels.IsAllowed(modelName) {
isAllowed = true
break
}
}
}
}
if isAllowed {
filteredModels = append(filteredModels, model)
}
}
return filteredModels
}

View File

@@ -0,0 +1 @@
1.5.4