Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- fix: idle timeout panic in the streaming idle-timeout reader
- fix: short-circuit `IdleTimeoutReader` reads when the connection is already closed (#3672)
- fix: preserve tool call stop reason in Anthropic streaming fallback (#3640) (thanks [@dicnunz](https://github.com/dicnunz)!)
- fix: correct start-time setting for accurate TTFT metric value (#3668)
- fix: map Vertex traffic type to Bifrost service tier (#3662)
- fix: ListModels for keyless providers (#3655)
- fix: remove manual `type: custom` for Anthropic tools (#3652)
5 changes: 5 additions & 0 deletions framework/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- feat: `created_by` user attribution column for virtual keys (#3672)
- feat: `blacklisted_models` column for virtual key provider configs (#3653)
- fix: add monotonic `inc_number` log cursor so node usage reconciliation does not skip late async log writes (#3664)
- revert: `access_profile_id` direct access profile assignment on virtual keys (#3669)
- chore: drop the `access_profile_id` column from `governance_virtual_keys` (#3670)
114 changes: 114 additions & 0 deletions framework/configstore/tables/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package tables
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"

"github.com/maximhq/bifrost/core/schemas"
Expand Down Expand Up @@ -39,10 +41,91 @@ type TablePlugin struct {
// TableName sets the table name for each model
func (TablePlugin) TableName() string { return "config_plugins" }

// ErrUnresolvedEnvVars is returned when a plugin config references environment variables
// that are not set in the process environment at save time.
type ErrUnresolvedEnvVars struct {
Vars []string
}

func (e *ErrUnresolvedEnvVars) Error() string {
return fmt.Sprintf("environment variables not set: %s", strings.Join(e.Vars, ", "))
}

// normalizePluginConfigForStorage recursively walks a plugin config map and converts
// any EnvVar-shaped object ({value, env_var, from_env}) into a plain string —
// either the literal value or the "env.VAR_NAME" token. This keeps stored JSON
// consistent with the plain-string format used by config.json and ProxyConfig.MarshalForStorage.
func normalizePluginConfigForStorage(v any) any {
switch val := v.(type) {
case map[string]any:
// Detect an EnvVar object: presence of any EnvVar key is sufficient — sparse
// objects (e.g. only from_env+env_var, without value) are valid.
_, hasValue := val["value"]
_, hasEnvVar := val["env_var"]
_, hasFromEnv := val["from_env"]
if hasValue || hasEnvVar || hasFromEnv {
if fromEnv, ok := val["from_env"].(bool); ok && fromEnv {
if envVar, ok := val["env_var"].(string); ok && envVar != "" {
return envVar // "env.VAR_NAME"
}
Comment on lines +67 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Missing "env." prefix causes complete roundtrip failure

The UI's normalizeEnvVar strips the "env." prefix from user input (e.g. "env.OTEL_COLLECTOR_URL"{env_var: "OTEL_COLLECTOR_URL"}), so val["env_var"] contains the bare name. The return here produces "OTEL_COLLECTOR_URL" instead of "env.OTEL_COLLECTOR_URL". Every downstream consumer gates on strings.HasPrefix(val, "env."): denormalizePluginConfigFromStorage won't expand it back to an EnvVar object, checkEnvVarsResolved never fires (making ErrUnresolvedEnvVars dead code for UI-submitted configs), and schemas.NewEnvVar("OTEL_COLLECTOR_URL") at plugin-init time has no prefix so it returns the literal string "OTEL_COLLECTOR_URL" from GetValue(), causing the plugin to fail to connect.

Fix: change return envVar to return "env." + envVar.

}
if value, ok := val["value"].(string); ok {
return value
}
}
// Regular nested map — recurse
result := make(map[string]any, len(val))
for k, child := range val {
result[k] = normalizePluginConfigForStorage(child)
}
return result
case []any:
result := make([]any, len(val))
for i, item := range val {
result[i] = normalizePluginConfigForStorage(item)
}
return result
default:
return v
}
}

// checkEnvVarsResolved walks a normalized plugin config (after normalizePluginConfigForStorage)
// and returns any "env.VAR_NAME" references whose env var is not set in the process environment.
func checkEnvVarsResolved(v any) []string {
var unresolved []string
switch val := v.(type) {
case string:
if strings.HasPrefix(val, "env.") {
envKey := strings.TrimPrefix(val, "env.")
if _, ok := os.LookupEnv(envKey); !ok {
unresolved = append(unresolved, val)
}
}
case map[string]any:
for _, child := range val {
unresolved = append(unresolved, checkEnvVarsResolved(child)...)
}
case []any:
for _, item := range val {
unresolved = append(unresolved, checkEnvVarsResolved(item)...)
}
}
return unresolved
}

// BeforeSave is a GORM hook that serializes the plugin Config into a JSON column and
// encrypts it before writing to the database. Empty configs ("{}") are not encrypted.
func (p *TablePlugin) BeforeSave(tx *gorm.DB) error {
if p.Config != nil {
// Normalize any EnvVar-shaped objects to plain strings before marshaling
// so the DB always stores "env.VAR_NAME" or literal values, not JSON objects.
if configMap, ok := p.Config.(map[string]any); ok {
p.Config = normalizePluginConfigForStorage(configMap)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if unresolved := checkEnvVarsResolved(p.Config); len(unresolved) > 0 {
return &ErrUnresolvedEnvVars{Vars: unresolved}
}
data, err := json.Marshal(p.Config)
if err != nil {
return err
Expand All @@ -65,6 +148,34 @@ func (p *TablePlugin) BeforeSave(tx *gorm.DB) error {
return nil
}

// denormalizePluginConfigFromStorage is the inverse of normalizePluginConfigForStorage.
// It converts plain "env.VAR_NAME" strings back into {value, env_var, from_env} objects
// so the API response carries the same shape as provider key EnvVar fields.
func denormalizePluginConfigFromStorage(v any) any {
switch val := v.(type) {
case string:
if strings.HasPrefix(val, "env.") {
redacted := schemas.NewEnvVar(val).Redacted()
return map[string]any{"value": redacted.Val, "env_var": redacted.EnvVar, "from_env": redacted.FromEnv}
}
return val
case map[string]any:
result := make(map[string]any, len(val))
for k, child := range val {
result[k] = denormalizePluginConfigFromStorage(child)
}
return result
case []any:
result := make([]any, len(val))
for i, item := range val {
result[i] = denormalizePluginConfigFromStorage(item)
}
return result
default:
return v
}
}

// AfterFind is a GORM hook that decrypts the plugin config JSON (if encrypted) and
// deserializes it back into the runtime Config field after reading from the database.
func (p *TablePlugin) AfterFind(tx *gorm.DB) error {
Expand All @@ -79,6 +190,9 @@ func (p *TablePlugin) AfterFind(tx *gorm.DB) error {
if err := json.Unmarshal([]byte(p.ConfigJSON), &p.Config); err != nil {
return err
}
if configMap, ok := p.Config.(map[string]any); ok {
p.Config = denormalizePluginConfigFromStorage(configMap)
}
} else {
p.Config = nil
}
Expand Down
1 change: 1 addition & 0 deletions plugins/compat/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
2 changes: 2 additions & 0 deletions plugins/governance/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
- feat: virtual key blocked-models enforcement — reject requests when the requested model is blocked at the VK provider-config level (#3653)
- fix: clear stale `governanceRejectedContextKey` on an allow decision so successful fallback retries count toward budgets and rate limits (#3645)
1 change: 1 addition & 0 deletions plugins/jsonparser/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
1 change: 1 addition & 0 deletions plugins/logging/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- feat: stamp MCP tool logs with governance ownership (user, team, customer, and business unit IDs) from the request context
1 change: 1 addition & 0 deletions plugins/maxim/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
21 changes: 11 additions & 10 deletions plugins/maxim/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,11 @@ const (
)

// Config is the configuration for the maxim plugin.
// - APIKey: API key for Maxim SDK authentication
// - LogRepoID: Optional default ID for the Maxim logger instance
// - APIKey: API key for Maxim SDK authentication; supports env.VAR_NAME
// - LogRepoID: Optional default ID for the Maxim logger instance; supports env.VAR_NAME
type Config struct {
LogRepoID string `json:"log_repo_id,omitempty"` // Optional - can be empty
APIKey string `json:"api_key"`
LogRepoID schemas.EnvVar `json:"log_repo_id,omitempty"` // Optional; supports env.VAR_NAME
APIKey schemas.EnvVar `json:"api_key"` // supports env.VAR_NAME
}

// Plugin implements the schemas.LLMPlugin interface for Maxim's logger.
Expand Down Expand Up @@ -63,27 +63,28 @@ func Init(config *Config, logger schemas.Logger) (schemas.LLMPlugin, error) {
return nil, fmt.Errorf("config is required")
}
// check if Maxim Logger variables are set
if config.APIKey == "" {
if config.APIKey.GetValue() == "" {
return nil, fmt.Errorf("apiKey is not set")
}

mx := maxim.Init(&maxim.MaximSDKConfig{ApiKey: config.APIKey})
mx := maxim.Init(&maxim.MaximSDKConfig{ApiKey: config.APIKey.GetValue()})

logRepoID := config.LogRepoID.GetValue()
plugin := &Plugin{
mx: mx,
defaultLogRepoID: config.LogRepoID,
defaultLogRepoID: logRepoID,
loggers: make(map[string]*logging.Logger),
loggerMutex: &sync.RWMutex{},
logger: logger,
}

// Initialize default logger if LogRepoId is provided
if config.LogRepoID != "" {
logger, err := mx.GetLogger(&logging.LoggerConfig{Id: config.LogRepoID})
if logRepoID != "" {
logger, err := mx.GetLogger(&logging.LoggerConfig{Id: logRepoID})
if err != nil {
return nil, fmt.Errorf("failed to initialize default logger: %w", err)
}
plugin.loggers[config.LogRepoID] = logger
plugin.loggers[logRepoID] = logger
}

return plugin, nil
Expand Down
18 changes: 9 additions & 9 deletions plugins/maxim/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ func getPlugin() (schemas.LLMPlugin, error) {

logger := bifrost.NewDefaultLogger(schemas.LogLevelDebug)
plugin, err := Init(&Config{
APIKey: os.Getenv("MAXIM_API_KEY"),
LogRepoID: os.Getenv("MAXIM_LOG_REPO_ID"),
APIKey: *schemas.NewEnvVar("env.MAXIM_API_KEY"),
LogRepoID: *schemas.NewEnvVar("env.MAXIM_LOG_REPO_ID"),
}, logger)
if err != nil {
return nil, err
Expand Down Expand Up @@ -208,24 +208,24 @@ func TestPluginInitialization(t *testing.T) {
{
name: "Valid config with both fields",
config: Config{
APIKey: "test-api-key",
LogRepoID: "test-repo-id",
APIKey: *schemas.NewEnvVar("test-api-key"),
LogRepoID: *schemas.NewEnvVar("test-repo-id"),
},
expectError: false,
},
{
name: "Valid config with only API key",
config: Config{
APIKey: "test-api-key",
LogRepoID: "",
APIKey: *schemas.NewEnvVar("test-api-key"),
LogRepoID: *schemas.NewEnvVar(""),
},
expectError: false,
},
{
name: "Invalid config - missing API key",
config: Config{
APIKey: "",
LogRepoID: "test-repo-id",
APIKey: *schemas.NewEnvVar(""),
LogRepoID: *schemas.NewEnvVar("test-repo-id"),
},
expectError: true,
},
Expand All @@ -242,7 +242,7 @@ func TestPluginInitialization(t *testing.T) {
} else {
// For valid configs, we can't test actual initialization without real API key
// Just test the validation logic
if tt.config.APIKey == "" {
if tt.config.APIKey.GetValue() == "" {
t.Skip("Skipping valid config test - would need real Maxim API key")
}
}
Expand Down
1 change: 1 addition & 0 deletions plugins/mocker/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
1 change: 1 addition & 0 deletions plugins/otel/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
27 changes: 16 additions & 11 deletions plugins/otel/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,16 +61,16 @@ type PluginSpanFilter struct {

type Config struct {
ServiceName string `json:"service_name"`
CollectorURL string `json:"collector_url"`
CollectorURL schemas.EnvVar `json:"collector_url"` // supports env.VAR_NAME
Headers map[string]string `json:"headers"`
TraceType TraceType `json:"trace_type"`
Protocol Protocol `json:"protocol"`
TLSCACert string `json:"tls_ca_cert"`
Insecure bool `json:"insecure"` // Skip TLS when true; ignored if TLSCACert is set. Defaults to true when omitted.

// Metrics push configuration
MetricsEnabled bool `json:"metrics_enabled"`
MetricsEndpoint string `json:"metrics_endpoint"`
MetricsEnabled bool `json:"metrics_enabled"`
MetricsEndpoint schemas.EnvVar `json:"metrics_endpoint"` // supports env.VAR_NAME
MetricsPushInterval int `json:"metrics_push_interval"` // in seconds, default 15

// PluginSpanFilter is the DB-stored fallback when otel_plugin_span_filter is absent in config.json.
Expand Down Expand Up @@ -133,6 +133,9 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa
if config == nil {
return nil, fmt.Errorf("config is required")
}
if config.CollectorURL.GetValue() == "" {
return nil, fmt.Errorf("collector_url is required")
}
logger = _logger
if pricingManager == nil {
logger.Warn("otel plugin requires model catalog to calculate cost, all cost calculations will be skipped.")
Expand Down Expand Up @@ -187,28 +190,29 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa
if nodeName := firstNonEmpty(os.Getenv("MY_NODE_NAME"), os.Getenv("NODE_NAME")); nodeName != "" {
instanceAttrs = append(instanceAttrs, kvStr("k8s.node.name", nodeName))
}
collectorURL := config.CollectorURL.GetValue()
// Preparing the plugin
p := &OtelPlugin{
serviceName: config.ServiceName,
url: config.CollectorURL,
url: collectorURL,
traceType: config.TraceType,
headers: config.Headers,
protocol: config.Protocol,
pricingManager: pricingManager,
bifrostVersion: bifrostVersion,
attributesFromEnvironment: attributesFromEnvironment,
instanceAttrs: instanceAttrs,
pluginSpanFilter: config.PluginSpanFilter,
pluginSpanFilter: config.PluginSpanFilter,
}
p.ctx, p.cancel = context.WithCancel(ctx)
if config.Protocol == ProtocolGRPC {
p.client, err = NewOtelClientGRPC(config.CollectorURL, config.Headers, config.TLSCACert, config.Insecure)
p.client, err = NewOtelClientGRPC(collectorURL, config.Headers, config.TLSCACert, config.Insecure)
if err != nil {
return nil, err
}
}
if config.Protocol == ProtocolHTTP {
p.client, err = NewOtelClientHTTP(config.CollectorURL, config.Headers, config.TLSCACert, config.Insecure)
p.client, err = NewOtelClientHTTP(collectorURL, config.Headers, config.TLSCACert, config.Insecure)
if err != nil {
return nil, err
}
Expand All @@ -219,7 +223,8 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa

// Initialize metrics exporter if enabled
if config.MetricsEnabled {
if config.MetricsEndpoint == "" {
metricsEndpoint := config.MetricsEndpoint.GetValue()
if metricsEndpoint == "" {
return nil, fmt.Errorf("metrics_endpoint is required when metrics_enabled is true")
}
pushInterval := config.MetricsPushInterval
Expand All @@ -230,7 +235,7 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa
}
metricsConfig := &MetricsConfig{
ServiceName: config.ServiceName,
Endpoint: config.MetricsEndpoint,
Endpoint: metricsEndpoint,
Headers: config.Headers,
Protocol: config.Protocol,
TLSCACert: config.TLSCACert,
Expand All @@ -245,7 +250,7 @@ func Init(ctx context.Context, config *Config, _logger schemas.Logger, pricingMa
}
return nil, fmt.Errorf("failed to initialize metrics exporter: %w", err)
}
logger.Info("OTEL metrics push enabled, pushing to %s every %d seconds", config.MetricsEndpoint, pushInterval)
logger.Info("OTEL metrics push enabled, pushing to %s every %d seconds", metricsEndpoint, pushInterval)
}

return p, nil
Expand Down Expand Up @@ -295,7 +300,7 @@ func (p *OtelPlugin) ValidateConfig(config any) (*Config, error) {
otelConfig = *config
}
// Validating fields
if otelConfig.CollectorURL == "" {
if otelConfig.CollectorURL.GetValue() == "" {
return nil, fmt.Errorf("collector url is required")
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
if otelConfig.TraceType == "" {
Expand Down
1 change: 1 addition & 0 deletions plugins/prompts/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
1 change: 1 addition & 0 deletions plugins/semanticcache/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
1 change: 1 addition & 0 deletions plugins/telemetry/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- chore: upgraded core to v1.5.12 and framework to v1.3.12
Loading
Loading