first commit
This commit is contained in:
168
plugins/governance/allow_on_all_virtual_keys_test.go
Normal file
168
plugins/governance/allow_on_all_virtual_keys_test.go
Normal 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")
|
||||
}
|
||||
0
plugins/governance/changelog.md
Normal file
0
plugins/governance/changelog.md
Normal file
164
plugins/governance/go.mod
Normal file
164
plugins/governance/go.mod
Normal 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
398
plugins/governance/go.sum
Normal 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=
|
||||
369
plugins/governance/http_transport_prehook_test.go
Normal file
369
plugins/governance/http_transport_prehook_test.go
Normal 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
1633
plugins/governance/main.go
Normal file
File diff suppressed because it is too large
Load Diff
2102
plugins/governance/modelprovidergovernance_test.go
Normal file
2102
plugins/governance/modelprovidergovernance_test.go
Normal file
File diff suppressed because it is too large
Load Diff
472
plugins/governance/resolver.go
Normal file
472
plugins/governance/resolver.go
Normal 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)
|
||||
}
|
||||
506
plugins/governance/resolver_test.go
Normal file
506
plugins/governance/resolver_test.go
Normal 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)
|
||||
}
|
||||
497
plugins/governance/routing.go
Normal file
497
plugins/governance/routing.go
Normal 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),
|
||||
)
|
||||
}
|
||||
1821
plugins/governance/routing_test.go
Normal file
1821
plugins/governance/routing_test.go
Normal file
File diff suppressed because it is too large
Load Diff
3391
plugins/governance/store.go
Normal file
3391
plugins/governance/store.go
Normal file
File diff suppressed because it is too large
Load Diff
124
plugins/governance/store_concurrency_test.go
Normal file
124
plugins/governance/store_concurrency_test.go
Normal 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))
|
||||
}
|
||||
|
||||
1169
plugins/governance/store_test.go
Normal file
1169
plugins/governance/store_test.go
Normal file
File diff suppressed because it is too large
Load Diff
350
plugins/governance/test_utils.go
Normal file
350
plugins/governance/test_utils.go
Normal 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)
|
||||
}
|
||||
320
plugins/governance/tracker.go
Normal file
320
plugins/governance/tracker.go
Normal 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
|
||||
}
|
||||
166
plugins/governance/tracker_test.go
Normal file
166
plugins/governance/tracker_test.go
Normal 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
143
plugins/governance/utils.go
Normal 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
|
||||
}
|
||||
1
plugins/governance/version
Normal file
1
plugins/governance/version
Normal file
@@ -0,0 +1 @@
|
||||
1.5.4
|
||||
Reference in New Issue
Block a user