diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index e20798ce8bb..01f72cff5ae 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -99,6 +99,7 @@ type ClientConfig struct { RoutingChainMaxDepth int `json:"routing_chain_max_depth"` // Maximum depth for routing rule chain evaluation (default: 10) MCPExternalClientURL *schemas.SecretVar `json:"mcp_external_client_url,omitempty"` // Public base URL used as redirect_uri when Bifrost acts as an OAuth client to upstream MCP servers. Supports env var syntax ("env.MY_VAR") ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) + DumpErrorsInConsoleLogs bool `json:"dump_errors_in_console_logs"` // Dump error details in console logs } // UnmarshalJSON defaults all bool fields to true when absent from JSON. @@ -236,6 +237,11 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) { hash.Write([]byte("asyncJobResultTTL:0")) } + // Only hash non-default value to avoid legacy config hash churn on upgrade. + if c.DumpErrorsInConsoleLogs { + hash.Write([]byte("dumpErrorsInConsoleLogs:true")) + } + // Hash integer fields data, err := sonic.Marshal(c.InitialPoolSize) if err != nil { diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index f6d0ab834d9..65fcc7a2935 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -429,6 +429,7 @@ var configstoreMigrationSteps = []migrationStep{ {IDs: []string{"add_customer_name_unique_constraint_dedup", "add_customer_name_unique_constraint_index"}, run: migrationAddCustomerNameUniqueConstraint}, {IDs: []string{"null_legacy_customer_budget_id_refs"}, run: migrationNullLegacyCustomerBudgetID}, {IDs: []string{"add_skills_repo_tables"}, run: migrationAddSkillsRepoTables}, + {IDs: []string{"add_dump_errors_in_console_logs_column"}, run: migrationAddDumpErrorsInConsoleLogsColumn}, } // quoteSQLiteIdentifier quotes a SQLite identifier, escaping any double quotes. @@ -4277,6 +4278,35 @@ func migrationAddDisableDBPingsInHealthColumn(ctx context.Context, db *gorm.DB, return nil } +// migrationAddDumpErrorsInConsoleLogsColumn adds the dump_errors_in_console_logs column to the client config table +func migrationAddDumpErrorsInConsoleLogsColumn(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { + migrationName := "add_dump_errors_in_console_logs_column" + logger.Info("[configstore] starting migration %s", migrationName) + defer logger.Info("[configstore] finished migration %s", migrationName) + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: migrationName, + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := addColumnIfNotExists(tx, logger, &tables.TableClientConfig{}, "dump_errors_in_console_logs"); err != nil { + return err + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + if err := dropColumnIfExists(tx, logger, &tables.TableClientConfig{}, "dump_errors_in_console_logs"); err != nil { + return err + } + return nil + }, + }}) + err := m.Migrate() + if err != nil { + return fmt.Errorf("error while running db migration: %s", err.Error()) + } + return nil +} + // migrationAddIsPingAvailableColumnToMCPClientTable adds the is_ping_available column to the config_mcp_clients table func migrationAddIsPingAvailableColumnToMCPClientTable(ctx context.Context, db *gorm.DB, logger schemas.Logger) error { migrationName := "add_is_ping_available_column" diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 48a01bb2d69..3568b6ba080 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -237,6 +237,7 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC EnableLogging: config.EnableLogging, DisableContentLogging: config.DisableContentLogging, DisableDBPingsInHealth: config.DisableDBPingsInHealth, + DumpErrorsInConsoleLogs: config.DumpErrorsInConsoleLogs, LogRetentionDays: config.LogRetentionDays, EnforceAuthOnInference: config.EnforceAuthOnInference, EnforceGovernanceHeader: config.EnforceGovernanceHeader, @@ -500,6 +501,7 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er EnableLogging: dbConfig.EnableLogging, DisableContentLogging: dbConfig.DisableContentLogging, DisableDBPingsInHealth: dbConfig.DisableDBPingsInHealth, + DumpErrorsInConsoleLogs: dbConfig.DumpErrorsInConsoleLogs, LogRetentionDays: dbConfig.LogRetentionDays, EnforceAuthOnInference: dbConfig.EnforceAuthOnInference, EnforceGovernanceHeader: dbConfig.EnforceGovernanceHeader, diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index ee1a0d395e1..642100f5460 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -20,6 +20,7 @@ type TableClientConfig struct { EnableLogging *bool `gorm:"default:true" json:"enable_logging"` DisableContentLogging bool `gorm:"default:false" json:"disable_content_logging"` // DisableContentLogging controls whether sensitive content (inputs, outputs, embeddings, etc.) is logged DisableDBPingsInHealth bool `gorm:"default:false" json:"disable_db_pings_in_health"` + DumpErrorsInConsoleLogs bool `gorm:"default:false" json:"dump_errors_in_console_logs"` // Dump full error details to the server console logs LogRetentionDays int `gorm:"default:365" json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) EnforceAuthOnInference bool `gorm:"default:false" json:"enforce_auth_on_inference"` EnforceGovernanceHeader bool `gorm:"" json:"enforce_governance_header"` diff --git a/helm-charts/bifrost/README.md b/helm-charts/bifrost/README.md index 3a90787f131..43b5009e60c 100644 --- a/helm-charts/bifrost/README.md +++ b/helm-charts/bifrost/README.md @@ -687,6 +687,7 @@ bifrost: | Parameter | Description | Default | | --------------------------------------------- | ------------------------------------------- | ------- | | `bifrost.client.disableDbPingsInHealth` | Disable DB pings in health check | `false` | +| `bifrost.client.dumpErrorsInConsoleLogs` | Dump full error details to server console | `false` | | `bifrost.client.headerFilterConfig.allowlist` | Headers allowed to forward to LLM providers | `[]` | | `bifrost.client.headerFilterConfig.denylist` | Headers blocked from forwarding | `[]` | diff --git a/helm-charts/bifrost/templates/_helpers.tpl b/helm-charts/bifrost/templates/_helpers.tpl index c6ded722376..19bb63f22b0 100644 --- a/helm-charts/bifrost/templates/_helpers.tpl +++ b/helm-charts/bifrost/templates/_helpers.tpl @@ -268,6 +268,9 @@ false {{- if hasKey .Values.bifrost.client "disableDbPingsInHealth" }} {{- $_ := set $client "disable_db_pings_in_health" .Values.bifrost.client.disableDbPingsInHealth }} {{- end }} +{{- if hasKey .Values.bifrost.client "dumpErrorsInConsoleLogs" }} +{{- $_ := set $client "dump_errors_in_console_logs" .Values.bifrost.client.dumpErrorsInConsoleLogs }} +{{- end }} {{- if .Values.bifrost.client.headerFilterConfig }} {{- $headerFilter := dict }} {{- if .Values.bifrost.client.headerFilterConfig.allowlist }} diff --git a/helm-charts/bifrost/values.schema.json b/helm-charts/bifrost/values.schema.json index 3c429d9ed47..3b471dc237d 100644 --- a/helm-charts/bifrost/values.schema.json +++ b/helm-charts/bifrost/values.schema.json @@ -383,6 +383,11 @@ "description": "Disable DB pings in health check", "default": false }, + "dumpErrorsInConsoleLogs": { + "type": "boolean", + "description": "Dump full error details to the server console logs. Useful for debugging; may be noisy in production.", + "default": false + }, "logRetentionDays": { "type": "integer", "minimum": 1, diff --git a/helm-charts/bifrost/values.yaml b/helm-charts/bifrost/values.yaml index b8d61e5b86b..0a926949921 100644 --- a/helm-charts/bifrost/values.yaml +++ b/helm-charts/bifrost/values.yaml @@ -262,6 +262,7 @@ bifrost: enableLogging: true disableContentLogging: false disableDbPingsInHealth: false + dumpErrorsInConsoleLogs: false logRetentionDays: 365 # Deprecated: use enforceAuthOnInference instead. enforceGovernanceHeader: false diff --git a/tests/cmd/e2eseed/go.mod b/tests/cmd/e2eseed/go.mod index fe7f6b2a1ba..77911a260d8 100644 --- a/tests/cmd/e2eseed/go.mod +++ b/tests/cmd/e2eseed/go.mod @@ -80,7 +80,7 @@ require ( 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/maximhq/bifrost/core v1.5.21 // indirect + github.com/maximhq/bifrost/core v1.5.22 // indirect github.com/maximhq/bifrost/framework v1.3.16 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect diff --git a/tests/cmd/seed/go.mod b/tests/cmd/seed/go.mod index edccf3d7f06..a9cbfa842ab 100644 --- a/tests/cmd/seed/go.mod +++ b/tests/cmd/seed/go.mod @@ -8,7 +8,7 @@ replace ( ) require ( - github.com/maximhq/bifrost/core v1.5.21 + github.com/maximhq/bifrost/core v1.5.22 github.com/maximhq/bifrost/framework v1.3.16 gorm.io/driver/postgres v1.6.0 gorm.io/driver/sqlite v1.6.0 diff --git a/tests/cmd/seedvks/go.mod b/tests/cmd/seedvks/go.mod index f60ad8f9bbc..fca1991b81f 100644 --- a/tests/cmd/seedvks/go.mod +++ b/tests/cmd/seedvks/go.mod @@ -9,7 +9,7 @@ replace ( require ( github.com/google/uuid v1.6.0 - github.com/maximhq/bifrost/core v1.5.21 + github.com/maximhq/bifrost/core v1.5.22 github.com/maximhq/bifrost/framework v1.3.16 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index 2ecd8c64486..ec98657b829 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -443,6 +443,9 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { // and ReloadClientConfigFromConfigStore mutates the struct in place so the next request picks up the new value. updatedConfig.DisableContentLogging = payload.ClientConfig.DisableContentLogging updatedConfig.DisableDBPingsInHealth = payload.ClientConfig.DisableDBPingsInHealth + // No restart needed - ReloadClientConfigFromConfigStore calls CorsMiddleware.UpdateConfig, + // which atomically swaps in a fresh immutable snapshot carrying the new value. + updatedConfig.DumpErrorsInConsoleLogs = payload.ClientConfig.DumpErrorsInConsoleLogs updatedConfig.EnforceAuthOnInference = payload.ClientConfig.EnforceAuthOnInference // Sync deprecated columns to match new field so they stay consistent in the DB diff --git a/transports/bifrost-http/handlers/middlewares.go b/transports/bifrost-http/handlers/middlewares.go index e27513937fe..4474700a1a5 100644 --- a/transports/bifrost-http/handlers/middlewares.go +++ b/transports/bifrost-http/handlers/middlewares.go @@ -68,10 +68,62 @@ func clientForwardedIP(ctx *fasthttp.RequestCtx) string { return "" } +// corsMiddlewareConfig is an immutable snapshot of the CORS-relevant client config. +// The slices are cloned at construction so a hot reload mutating the source +// ClientConfig in place cannot race with in-flight requests reading these fields. +type corsMiddlewareConfig struct { + dumpErrorsInConsoleLogs bool + allowedOrigins []string + allowedHeaders []string +} + +// newCorsMiddlewareConfig builds an immutable snapshot from the live config, +// cloning the slices so the snapshot never aliases the shared ClientConfig. +func newCorsMiddlewareConfig(config *lib.Config) *corsMiddlewareConfig { + if config == nil || config.ClientConfig == nil { + return nil + } + return &corsMiddlewareConfig{ + dumpErrorsInConsoleLogs: config.ClientConfig.DumpErrorsInConsoleLogs, + allowedOrigins: slices.Clone(config.ClientConfig.AllowedOrigins), + allowedHeaders: slices.Clone(config.ClientConfig.AllowedHeaders), + } +} + +// CorsMiddleware handles CORS headers for localhost and configured allowed origins. +// The snapshot is held in an atomic.Pointer so UpdateConfig can swap it at runtime +// without racing in-flight requests, which read the pointer concurrently. Because the +// snapshot is immutable (slices cloned), readers never observe a torn or half-updated +// config even while a reload swaps in a new one. +type CorsMiddleware struct { + config atomic.Pointer[corsMiddlewareConfig] +} + +func NewCorsMiddleware(config *lib.Config) *CorsMiddleware { + c := &CorsMiddleware{} + c.config.Store(newCorsMiddlewareConfig(config)) + return c +} + +// UpdateConfig atomically swaps in a fresh immutable snapshot of the configuration. +// In-flight requests reading the pointer observe either the old or the new snapshot, +// never a torn value. ReloadClientConfigFromConfigStore must call this whenever the +// client config is refreshed, mirroring how AuthMiddleware is updated. +func (c *CorsMiddleware) UpdateConfig(config *lib.Config) { + c.config.Store(newCorsMiddlewareConfig(config)) +} + // CorsMiddleware handles CORS headers for localhost and configured allowed origins -func CorsMiddleware(config *lib.Config) schemas.BifrostHTTPMiddleware { +func (c *CorsMiddleware) Middleware() schemas.BifrostHTTPMiddleware { return func(next fasthttp.RequestHandler) fasthttp.RequestHandler { return func(ctx *fasthttp.RequestCtx) { + // Snapshot the config once per request so a concurrent UpdateConfig swap + // cannot apply two different configs within a single response. + cfg := c.config.Load() + if cfg == nil { + SendError(ctx, fasthttp.StatusInternalServerError, "CORS middleware configuration not loaded") + return + } shouldLog := slices.IndexFunc(loggingSkipPaths, func(path string) bool { return strings.HasPrefix(string(ctx.RequestURI()), path) }) == -1 @@ -98,19 +150,26 @@ func CorsMiddleware(config *lib.Config) schemas.BifrostHTTPMiddleware { if traceID, ok := ctx.UserValue(schemas.BifrostContextKeyTraceID).(string); ok && traceID != "" { logBuilder = logBuilder.Str("trace_id", traceID) } + if cfg.dumpErrorsInConsoleLogs { + if statusCode >= 400 && !ctx.Response.IsBodyStream() { + if body := ctx.Response.Body(); len(body) > 0 { + logBuilder = logBuilder.Str("http.error", string(body)) + } + } + } logBuilder.Send() }() } origin := string(ctx.Request.Header.Peek("Origin")) - allowed := IsOriginAllowed(origin, config.ClientConfig.AllowedOrigins) + allowed := IsOriginAllowed(origin, cfg.allowedOrigins) // Credentialed responses are sent when the origin is not matched solely by a // wildcard AllowedOrigins — i.e. the origin is localhost or explicitly listed. - credentialed := !slices.Contains(config.ClientConfig.AllowedOrigins, "*") || + credentialed := !slices.Contains(cfg.allowedOrigins, "*") || isLocalhostOrigin(origin) || - slices.Contains(config.ClientConfig.AllowedOrigins, origin) + slices.Contains(cfg.allowedOrigins, origin) allowedHeaders := []string{"Content-Type", "Authorization", "X-Requested-With", "X-Stainless-Timeout", "X-Api-Key", "X-OpenAI-Agents-SDK", "X-Operation-ID"} - if slices.Contains(config.ClientConfig.AllowedHeaders, "*") { + if slices.Contains(cfg.allowedHeaders, "*") { if credentialed { // Per the Fetch spec, Access-Control-Allow-Headers: * is NOT treated as a // wildcard when Access-Control-Allow-Credentials: true is set — browsers @@ -123,9 +182,9 @@ func CorsMiddleware(config *lib.Config) schemas.BifrostHTTPMiddleware { } else { allowedHeaders = []string{"*"} } - } else if len(config.ClientConfig.AllowedHeaders) > 0 { + } else if len(cfg.allowedHeaders) > 0 { // append allowed headers from config to the default headers - for _, header := range config.ClientConfig.AllowedHeaders { + for _, header := range cfg.allowedHeaders { if !slices.Contains(allowedHeaders, header) { allowedHeaders = append(allowedHeaders, header) } diff --git a/transports/bifrost-http/handlers/middlewares_test.go b/transports/bifrost-http/handlers/middlewares_test.go index 89c6587a9fa..c149a70b1e9 100644 --- a/transports/bifrost-http/handlers/middlewares_test.go +++ b/transports/bifrost-http/handlers/middlewares_test.go @@ -63,7 +63,7 @@ func TestCorsMiddleware_LocalhostOrigins(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -109,7 +109,7 @@ func TestCorsMiddleware_ConfiguredOrigins(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -140,7 +140,7 @@ func TestCorsMiddleware_NonAllowedOrigins(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -172,7 +172,7 @@ func TestCorsMiddleware_PreflightAllowedOrigin(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -209,7 +209,7 @@ func TestCorsMiddleware_PreflightNonAllowedOrigin(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -246,7 +246,7 @@ func TestCorsMiddleware_PreflightLocalhost(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -282,7 +282,7 @@ func TestCorsMiddleware_NoOriginHeader(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1083,7 +1083,7 @@ func TestCorsMiddleware_DefaultHeaders(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1119,7 +1119,7 @@ func TestCorsMiddleware_WildcardHeaders_NonCredentialed(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1156,7 +1156,7 @@ func TestCorsMiddleware_WildcardHeaders_CredentialedPreflight(t *testing.T) { t.Error("Next handler should not be called for preflight") } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1193,7 +1193,7 @@ func TestCorsMiddleware_WildcardHeaders_CredentialedNonPreflight(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1233,7 +1233,7 @@ func TestCorsMiddleware_CustomHeaders(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1279,7 +1279,7 @@ func TestCorsMiddleware_DuplicateHeaders(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1321,7 +1321,7 @@ func TestCorsMiddleware_CustomHeadersWithLocalhost(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) @@ -1355,7 +1355,7 @@ func TestCorsMiddleware_CustomHeadersNotSetForNonAllowedOrigin(t *testing.T) { nextCalled = true } - middleware := CorsMiddleware(config) + middleware := NewCorsMiddleware(config).Middleware() handler := middleware(next) handler(ctx) diff --git a/transports/bifrost-http/handlers/realtime_turn_pipeline.go b/transports/bifrost-http/handlers/realtime_turn_pipeline.go index 947185ee97d..7286dcc6382 100644 --- a/transports/bifrost-http/handlers/realtime_turn_pipeline.go +++ b/transports/bifrost-http/handlers/realtime_turn_pipeline.go @@ -28,9 +28,17 @@ func newRealtimeTurnContext( // Realtime post-hook contexts must preserve plugin-private values written in // pre-hooks (for example telemetry start timestamps), not just public keys. for ctxKey, value := range baseCtx.GetUserValues() { - if value != nil { - ctx.SetValue(ctxKey, value) + if value == nil { + continue + } + // Never inherit a session/transport-level trace ID. Each realtime turn + // must mint its own trace in RunRealtimeTurnPreHooks so its log entry is + // delivered when the turn's trace is completed and flushed. Inheriting a + // trace whose lifecycle is owned elsewhere strands the entry forever. + if ctxKey == schemas.BifrostContextKeyTraceID { + continue } + ctx.SetValue(ctxKey, value) } } diff --git a/transports/bifrost-http/handlers/wsrealtime.go b/transports/bifrost-http/handlers/wsrealtime.go index 761bc269ef8..7f9da1a7279 100644 --- a/transports/bifrost-http/handlers/wsrealtime.go +++ b/transports/bifrost-http/handlers/wsrealtime.go @@ -830,7 +830,11 @@ var realtimeMiddlewareKeys = []any{ schemas.BifrostContextKeyAPIKeyName, schemas.BifrostContextKeySelectedKeyID, schemas.BifrostContextKeySelectedKeyName, - schemas.BifrostContextKeyTraceID, + // NOTE: BifrostContextKeyTraceID is intentionally NOT inherited here. The + // upgrade request's trace is already ended by the time realtime turns run, so + // inheriting it would route each turn's log entry into pendingLogsToInject + // under a dead trace ID whose Inject() never fires, dropping the row. Each + // realtime turn mints its own trace in RunRealtimeTurnPreHooks instead. schemas.BifrostContextKeyTransportPluginLogs, } diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 877df2387c2..70121978956 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -156,6 +156,7 @@ type BifrostHTTPServer struct { IntegrationHandler *handlers.IntegrationHandler AuthMiddleware *handlers.AuthMiddleware + CORSMiddleware *handlers.CorsMiddleware TracingMiddleware *handlers.TracingMiddleware WSTicketStore *handlers.WSTicketStore TempTokens *temptoken.Service @@ -836,6 +837,12 @@ func (s *BifrostHTTPServer) ReloadClientConfigFromConfigStore(ctx context.Contex s.AuthMiddleware.UpdateWhitelistedRoutes(config.WhitelistedRoutes) s.AuthMiddleware.UpdateTempTokenAuthEnabled(config.MCPEnableTempTokenAuth) } + // Refresh the CORS middleware's immutable snapshot so its requests pick up the + // new AllowedOrigins/AllowedHeaders/DumpErrorsInConsoleLogs without racing the + // in-place ClientConfig mutation above. + if s.CORSMiddleware != nil { + s.CORSMiddleware.UpdateConfig(s.Config) + } // Reloading config in bifrost client if s.Client != nil { account := lib.NewBaseAccount(s.Config) @@ -1705,6 +1712,8 @@ func (s *BifrostHTTPServer) Bootstrap(ctx context.Context) error { s.Config.SetBifrostClient(s.Client) // Initialize routes s.Router = router.New() + // Initialize CORS middleware + s.CORSMiddleware = handlers.NewCorsMiddleware(s.Config) commonMiddlewares := s.PrepareCommonMiddlewares() apiMiddlewares := commonMiddlewares inferenceMiddlewares := commonMiddlewares @@ -1827,7 +1836,7 @@ func (s *BifrostHTTPServer) Bootstrap(ctx context.Context) error { logger.Debug("server read buffer size: %d", s.Config.ServerConfig.ReadBufferSize) // Create fasthttp server instance s.Server = &fasthttp.Server{ - Handler: handlers.SecurityHeadersMiddleware()(handlers.CorsMiddleware(s.Config)(handlers.RequestDecompressionMiddleware(s.Config)(s.Router.Handler))), + Handler: handlers.SecurityHeadersMiddleware()(s.CORSMiddleware.Middleware()(handlers.RequestDecompressionMiddleware(s.Config)(s.Router.Handler))), MaxRequestBodySize: s.Config.ClientConfig.MaxRequestBodySizeMB * 1024 * 1024, ReadBufferSize: s.Config.ServerConfig.ReadBufferSize, } diff --git a/transports/config.schema.json b/transports/config.schema.json index 87153df4334..5760f102332 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -105,6 +105,11 @@ "description": "Disable DB pings in health check", "default": false }, + "dump_errors_in_console_logs": { + "type": "boolean", + "description": "Dump full error details to the server console logs. Useful for debugging; may be noisy in production.", + "default": false + }, "log_retention_days": { "type": "integer", "minimum": 1, diff --git a/ui/app/workspace/config/views/clientSettingsView.tsx b/ui/app/workspace/config/views/clientSettingsView.tsx index 76571d26de7..84a61d07ece 100644 --- a/ui/app/workspace/config/views/clientSettingsView.tsx +++ b/ui/app/workspace/config/views/clientSettingsView.tsx @@ -106,6 +106,7 @@ export default function ClientSettingsView() { return ( localConfig.drop_excess_requests !== config.drop_excess_requests || localConfig.disable_db_pings_in_health !== config.disable_db_pings_in_health || + localConfig.dump_errors_in_console_logs !== config.dump_errors_in_console_logs || localConfig.async_job_result_ttl !== config.async_job_result_ttl || !headerFilterConfigEqual(localConfig.header_filter_config, config.header_filter_config) ); @@ -335,6 +336,26 @@ export default function ClientSettingsView() { disabled={!hasSettingsUpdateAccess} /> + + {/* Dump Errors in Console Logs */} +
+
+ +

+ If enabled, full error details are written to the server console logs. Useful for debugging, but may be noisy in production. +

+
+ handleConfigChange("dump_errors_in_console_logs", checked)} + disabled={!hasSettingsUpdateAccess} + /> +
{/* Async Job Result TTL */}
diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index 6431f6a4564..89dd63a1d3e 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -538,6 +538,7 @@ export interface CoreConfig { allow_per_request_raw_override: boolean; allow_direct_keys: boolean; disable_db_pings_in_health: boolean; + dump_errors_in_console_logs: boolean; log_retention_days: number; enforce_auth_on_inference: boolean; allowed_origins: string[]; @@ -570,6 +571,7 @@ export const DefaultCoreConfig: CoreConfig = { allow_per_request_raw_override: false, allow_direct_keys: false, disable_db_pings_in_health: false, + dump_errors_in_console_logs: false, log_retention_days: 365, enforce_auth_on_inference: false, allowed_origins: [],