Skip to content
Open
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
46 changes: 23 additions & 23 deletions .github/workflows/scripts/schemasync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@ var ignoreSchemaProps = map[string]string{
"/properties/governance/properties/virtual_keys/items/properties/provider_configs": "gorm fk slice; user-submittable",
"/properties/governance/properties/virtual_keys/items/properties/mcp_configs": "gorm fk slice; user-submittable",
"/properties/governance/properties/routing_rules/items/properties/targets": "gorm fk slice; user-submittable",
// MCP headers map<string, EnvVar> — documented escape hatch is envFrom:
// MCP headers map<string, SecretVar> — documented escape hatch is envFrom:
// plus env.X references in values; no chart-native secretRef.
"/properties/mcp/properties/client_configs/items/properties/headers/additionalProperties": "documented envFrom pattern",
// Object-storage identity fields (bucket/region/endpoint/project_id) are
// EnvVar-typed for flexibility but are not inherently secret. Operators
// SecretVar-typed for flexibility but are not inherently secret. Operators
// can write `env.MY_VAR` in values and use envFrom to inject. Access
// keys, session tokens, and credentials DO have chart-native secret
// support via `storage.logsStore.objectStorage.existingSecret`.
Expand Down Expand Up @@ -118,15 +118,15 @@ var ignoreGoFieldNames = map[string]string{

// opaqueLeafTypes are named Go types that have custom JSON marshalling and
// should be treated as leaves. The walker does NOT recurse into their fields,
// and they are collected for downstream checks (e.g., EnvVar → helm secret).
// and they are collected for downstream checks (e.g., SecretVar → helm secret).
var opaqueLeafTypes = map[string]string{
"github.com/maximhq/bifrost/core/schemas.EnvVar": "env-aware string; custom JSON",
"github.com/maximhq/bifrost/core/schemas.SecretVar": "env-aware string; custom JSON",
}

// envVarLocation records where an EnvVar-typed field appears in config.json
// secretVarLocation records where an SecretVar-typed field appears in config.json
// so a downstream pass can confirm the helm chart supports Secret-backed
// injection (existingSecret / secretRef / env.BIFROST_*) for that path.
type envVarLocation struct {
type secretVarLocation struct {
schemaPath string
goPath string
}
Expand All @@ -148,15 +148,15 @@ type checker struct {
enumConsts map[string][]string
// visited type names to break cycles
visited map[string]bool
// envVarFields records where EnvVar types occur, for downstream checks
envVarFields []envVarLocation
// secretVarFields records where SecretVar types occur, for downstream checks
secretVarFields []secretVarLocation
findings []Finding
}

func main() {
schemaFlag := flag.String("schema", "transports/config.schema.json", "path to config.schema.json")
pkgDir := flag.String("pkg-root", ".", "repo root used as packages.Load dir")
helmValuesFlag := flag.String("helm-values", "helm-charts/bifrost/values.schema.json", "path to helm values.schema.json (for EnvVar secret-support check)")
helmValuesFlag := flag.String("helm-values", "helm-charts/bifrost/values.schema.json", "path to helm values.schema.json (for SecretVar secret-support check)")
helmHelpersFlag := flag.String("helm-helpers", "helm-charts/bifrost/templates/_helpers.tpl", "path to helm _helpers.tpl (for env.BIFROST_* emission detection)")
flag.Parse()

Expand Down Expand Up @@ -255,12 +255,12 @@ func main() {
c.walkType(named, e.schemaPath, fmt.Sprintf("%s.%s", e.pkg, e.typeName))
}

// EnvVar → helm-chart secret-support pass. For each Go field typed as
// schemas.EnvVar, the helm chart must either (a) emit an env.BIFROST_*
// SecretVar → helm-chart secret-support pass. For each Go field typed as
// schemas.SecretVar, the helm chart must either (a) emit an env.BIFROST_*
// placeholder for that JSON path via _helpers.tpl, or (b) expose a
// secretRef/existingSecret knob in values.schema.json at the equivalent
// camelCase location. If neither, warn.
c.checkEnvVarHelmSupport(*helmValuesFlag, *helmHelpersFlag)
c.checkSecretVarHelmSupport(*helmValuesFlag, *helmHelpersFlag)

printReport(os.Stderr, c.findings)
errCount := c.countErrs()
Expand Down Expand Up @@ -291,7 +291,7 @@ func printReport(w interface{ Write([]byte) (int, error) }, findings []Finding)
"missing-in-go": "Missing in Go (schema has property, ConfigData doesn't) — WARNINGS",
"enum-drift": "Enum drift (Go constants vs schema enum array)",
"enum-no-schema": "Go enum types with no schema `enum` constraint — WARNINGS",
"envvar-no-secret": "EnvVar fields lacking chart-native Secret support — WARNINGS",
"envvar-no-secret": "SecretVar fields lacking chart-native Secret support — WARNINGS",
"schema-path-not-found": "Schema path not found for a walked Go type — ERRORS",
"entrypoint": "Entrypoint problems — ERRORS",
}
Expand Down Expand Up @@ -395,7 +395,7 @@ func renderTable(w interface{ Write([]byte) (int, error) }, headers []string, ro
}
}

// checkEnvVarHelmSupport verifies that every Go field of type schemas.EnvVar
// checkSecretVarHelmSupport verifies that every Go field of type schemas.SecretVar
// has a way to be sourced from a Kubernetes secret via the helm chart. Proof
// of support is any of:
//
Expand All @@ -406,10 +406,10 @@ func renderTable(w interface{ Write([]byte) (int, error) }, headers []string, ro
//
// Neither heuristic is perfect — this is a structural review aid, not a
// proof. Treat misses as warnings so they don't block CI on borderline cases.
func (c *checker) checkEnvVarHelmSupport(valuesPath, helpersPath string) {
func (c *checker) checkSecretVarHelmSupport(valuesPath, helpersPath string) {
helpersBytes, err := os.ReadFile(helpersPath)
if err != nil {
c.add(Finding{Category: "envvar-no-secret", Severity: "WARN", Detail: fmt.Sprintf("could not read helm helpers %s: %v — skipping EnvVar helm-support check", helpersPath, err)})
c.add(Finding{Category: "envvar-no-secret", Severity: "WARN", Detail: fmt.Sprintf("could not read helm helpers %s: %v — skipping SecretVar helm-support check", helpersPath, err)})
return
}
helpers := string(helpersBytes)
Expand Down Expand Up @@ -444,9 +444,9 @@ func (c *checker) checkEnvVarHelmSupport(valuesPath, helpersPath string) {
_ = json.Unmarshal(valuesBytes, &valuesSchema)
}

for _, loc := range c.envVarFields {
for _, loc := range c.secretVarFields {
// Heuristic 1: any env.BIFROST_* is present in helpers — broad acceptance.
// We can't easily map a specific EnvVar field to a specific env var
// We can't easily map a specific SecretVar field to a specific env var
// without per-field config, so we just check that the helpers file
// has AT LEAST ONE envBifrost mention that maps to this field's path.
// To make this stricter, we look for a helpers line mentioning either
Expand Down Expand Up @@ -698,10 +698,10 @@ func (c *checker) walkType(t types.Type, schemaPath, goPath string) {
named, _ := t.(*types.Named)
if named != nil {
key := named.Obj().Pkg().Path() + "." + named.Obj().Name()
// Treat opaque types (like schemas.EnvVar) as leaves.
// Treat opaque types (like schemas.SecretVar) as leaves.
if _, isOpaque := opaqueLeafTypes[key]; isOpaque {
if key == "github.com/maximhq/bifrost/core/schemas.EnvVar" {
c.envVarFields = append(c.envVarFields, envVarLocation{schemaPath, goPath})
if key == "github.com/maximhq/bifrost/core/schemas.SecretVar" {
c.secretVarFields = append(c.secretVarFields, secretVarLocation{schemaPath, goPath})
}
return
}
Expand Down Expand Up @@ -805,8 +805,8 @@ func (c *checker) walkField(t types.Type, schemaNode map[string]any, schemaPath,
if named, ok := t.(*types.Named); ok {
key := named.Obj().Pkg().Path() + "." + named.Obj().Name()
if _, isOpaque := opaqueLeafTypes[key]; isOpaque {
if key == "github.com/maximhq/bifrost/core/schemas.EnvVar" {
c.envVarFields = append(c.envVarFields, envVarLocation{schemaPath, goPath})
if key == "github.com/maximhq/bifrost/core/schemas.SecretVar" {
c.secretVarFields = append(c.secretVarFields, secretVarLocation{schemaPath, goPath})
}
return // do not recurse into opaque types
}
Expand Down
Binary file added .serena/cache/go/document_symbols.pkl
Binary file not shown.
Binary file added .serena/cache/go/raw_document_symbols.pkl
Binary file not shown.
5 changes: 5 additions & 0 deletions .serena/project.local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# This file allows you to locally override settings in project.yml for development purposes.
#
# Use the same keys as in project.yml here. Any setting you specify will override the corresponding
# setting in project.yml, allowing you to customise the configuration for your local development environment
# without affecting the project configuration in project.yml (which is intended to be versioned).
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,13 @@ ctx.WithValue(key, value) // Chainable variant

**Gotcha**: `BlockRestrictedWrites()` silently drops writes to reserved keys. This prevents plugins from accidentally overwriting internal state.

**Hard rule — never store stream-sized data in `BifrostContext`.** Context holds small handles only: IDs, durations, booleans, interface pointers. Any per-request state that scales with stream content (chunk buffers, accumulated payloads, replay queues, large per-request slices/maps) must live in a top-level manager keyed by `RequestID`, not in `ctx`. Reference implementations:

- `framework/streaming.Accumulator` — owns a `sync.Map` of per-stream `StreamAccumulator` entries keyed by `RequestID`. Only `BifrostContextKeyAccumulatorID` (the ID string) is stored on the context; the chunk buffers live in the manager. The pause/resume gate (`gate.go`) extends the same per-stream entry with a state machine — again, **no buffer in ctx**.
- The `Tracer` interface (in ctx as a small pointer) is the access path for plugins/providers to reach managers without putting bulky data on the context itself.

When in doubt: if your new ctx key would hold a slice/map that grows with request content, route the storage through a manager and keep only the ID in ctx.

---

## Core Patterns
Expand Down
8 changes: 4 additions & 4 deletions core/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -5277,7 +5277,7 @@ func (bifrost *Bifrost) tryStreamRequest(ctx *schemas.BifrostContext, req *schem
pipeline.FinalizeStreamingPostHookSpans(ctx)
bifrost.releasePluginPipeline(pipeline)
}()
defer close(outputStream)
defer providerUtils.CloseStream(ctx, outputStream)

for streamMsg := range shortCircuit.Stream {
if streamMsg == nil {
Expand Down Expand Up @@ -5315,9 +5315,9 @@ func (bifrost *Bifrost) tryStreamRequest(ctx *schemas.BifrostContext, req *schem
// Guarded send: if the consumer abandons outputStream (client
// disconnect, ctx cancel), drain the upstream shortCircuit.Stream
// so its producer can exit cleanly instead of blocking on its send.
select {
case outputStream <- streamResponse:
case <-ctx.Done():
// GateSendChunk routes through the pause/resume gate when a plugin
// has engaged it; otherwise it's a bare ctx-guarded channel send.
if !providerUtils.GateSendChunk(ctx, streamResponse, outputStream) {
for range shortCircuit.Stream {
}
return
Expand Down
22 changes: 11 additions & 11 deletions core/bifrost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -725,7 +725,7 @@ func (ma *MockAccount) AddProviderWithBaseURL(provider schemas.ModelProvider, co
ma.keys[provider] = []schemas.Key{
{
ID: fmt.Sprintf("test-key-%s", provider),
Value: *schemas.NewEnvVar(fmt.Sprintf("sk-test-%s", provider)),
Value: *schemas.NewSecretVar(fmt.Sprintf("sk-test-%s", provider)),
Weight: 100,
},
}
Expand Down Expand Up @@ -943,8 +943,8 @@ func TestSelectKeyFromProviderForModel_SessionStickiness(t *testing.T) {
account.AddProvider(schemas.OpenAI, 5, 1000)
// Use 2 keys so we hit the keySelector path (single key returns early)
account.SetKeysForProvider(schemas.OpenAI, []schemas.Key{
{ID: "key-a", Name: "Key A", Value: *schemas.NewEnvVar("sk-a"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-b", Name: "Key B", Value: *schemas.NewEnvVar("sk-b"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-a", Name: "Key A", Value: *schemas.NewSecretVar("sk-a"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-b", Name: "Key B", Value: *schemas.NewSecretVar("sk-b"), Models: schemas.WhiteList{"*"}, Weight: 1},
})

var keySelectorCalls int
Expand Down Expand Up @@ -1010,8 +1010,8 @@ func TestSelectKeyFromProviderForModel_NoStickinessWithoutSessionID(t *testing.T
account := NewMockAccount()
account.AddProvider(schemas.OpenAI, 5, 1000)
account.SetKeysForProvider(schemas.OpenAI, []schemas.Key{
{ID: "key-a", Name: "Key A", Value: *schemas.NewEnvVar("sk-a"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-b", Name: "Key B", Value: *schemas.NewEnvVar("sk-b"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-a", Name: "Key A", Value: *schemas.NewSecretVar("sk-a"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-b", Name: "Key B", Value: *schemas.NewSecretVar("sk-b"), Models: schemas.WhiteList{"*"}, Weight: 1},
})

var keySelectorCalls int
Expand Down Expand Up @@ -1062,8 +1062,8 @@ func TestSelectKeyFromProviderForModel_SessionStickinessNoRotation(t *testing.T)
account := NewMockAccount()
account.AddProvider(schemas.OpenAI, 5, 1000)
account.SetKeysForProvider(schemas.OpenAI, []schemas.Key{
{ID: "key-a", Name: "Key A", Value: *schemas.NewEnvVar("sk-a"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-b", Name: "Key B", Value: *schemas.NewEnvVar("sk-b"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-a", Name: "Key A", Value: *schemas.NewSecretVar("sk-a"), Models: schemas.WhiteList{"*"}, Weight: 1},
{ID: "key-b", Name: "Key B", Value: *schemas.NewSecretVar("sk-b"), Models: schemas.WhiteList{"*"}, Weight: 1},
})

deterministicSelector := func(ctx *schemas.BifrostContext, keys []schemas.Key, _ schemas.ModelProvider, _ string) (schemas.Key, error) {
Expand Down Expand Up @@ -1147,7 +1147,7 @@ func TestSelectKeyFromProviderForModel_BlacklistedModels(t *testing.T) {

t.Run("all keys blacklist model", func(t *testing.T) {
account.SetKeysForProvider(schemas.OpenAI, []schemas.Key{
{ID: "k1", Name: "K1", Value: *schemas.NewEnvVar("sk-1"), Weight: 1, BlacklistedModels: []string{"gpt-4"}},
{ID: "k1", Name: "K1", Value: *schemas.NewSecretVar("sk-1"), Weight: 1, BlacklistedModels: []string{"gpt-4"}},
})
_, _, err := bifrost.selectKeyFromProviderForModelWithPool(bfCtx, schemas.ChatCompletionRequest, schemas.OpenAI, "gpt-4", schemas.OpenAI)
if err == nil {
Expand All @@ -1161,7 +1161,7 @@ func TestSelectKeyFromProviderForModel_BlacklistedModels(t *testing.T) {
t.Run("blacklist wins over models allow list", func(t *testing.T) {
account.SetKeysForProvider(schemas.OpenAI, []schemas.Key{
{
ID: "k1", Name: "K1", Value: *schemas.NewEnvVar("sk-1"), Weight: 1,
ID: "k1", Name: "K1", Value: *schemas.NewSecretVar("sk-1"), Weight: 1,
Models: []string{"gpt-4"},
BlacklistedModels: []string{"gpt-4"},
},
Expand All @@ -1174,8 +1174,8 @@ func TestSelectKeyFromProviderForModel_BlacklistedModels(t *testing.T) {

t.Run("second key used when first blacklists", func(t *testing.T) {
account.SetKeysForProvider(schemas.OpenAI, []schemas.Key{
{ID: "k1", Name: "K1", Value: *schemas.NewEnvVar("sk-1"), Weight: 1, BlacklistedModels: []string{"gpt-4"}},
{ID: "k2", Name: "K2", Value: *schemas.NewEnvVar("sk-2"), Weight: 1, Models: []string{"*"}},
{ID: "k1", Name: "K1", Value: *schemas.NewSecretVar("sk-1"), Weight: 1, BlacklistedModels: []string{"gpt-4"}},
{ID: "k2", Name: "K2", Value: *schemas.NewSecretVar("sk-2"), Weight: 1, Models: []string{"*"}},
})
pool, canRotate, err := bifrost.selectKeyFromProviderForModelWithPool(bfCtx, schemas.ChatCompletionRequest, schemas.OpenAI, "gpt-4", schemas.OpenAI)
if err != nil {
Expand Down
1 change: 1 addition & 0 deletions core/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- fix: deterministic MCP tool ordering for prompt cache stability (closes #2347)
Loading
Loading