Skip to content
Merged
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
5 changes: 5 additions & 0 deletions core/schemas/batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,11 @@ type BatchAccountingDebug struct {
// be parsed at all. Their usage is unrecoverable — the raw results are not
// persisted — so the count is kept as the record of how much the row omits.
ParseErrorCount int `json:"parse_error_count,omitempty"`
// Echo marks a read-only copy of another row's settled price, written onto the
// log row of a /results call that did not settle the batch. Such a row is never
// billed, so its NULL cost is final rather than pending: without this marker it
// looks like an unpriced row to every missing-cost query and recovery pass.
Echo bool `json:"echo,omitempty"`
// Incomplete marks a total that is known to under-state the batch: some
// usage-bearing row failed to price, or rows were lost to parse errors. It is
// only ever set, never cleared by a repricing pass — usage that never reached
Expand Down
73 changes: 73 additions & 0 deletions framework/logstore/missingcostbatchecho_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package logstore

import (
"context"
"testing"
"time"

"github.com/maximhq/bifrost/core/schemas"
"github.com/stretchr/testify/require"
)

// A /results call that did not settle the batch gets a log row carrying a read-only
// copy of the settled price and no cost of its own. That NULL cost is final — no
// recalculation will ever fill it — so the row must stay out of "show missing cost
// only", which it used to flood.
func TestMissingCostOnlyExcludesBatchEchoRows(t *testing.T) {
store := newTestSQLiteStore(t)
ctx := context.Background()
base := time.Date(2026, 3, 4, 5, 6, 0, 0, time.UTC)
settled := 1.25

newBatchRow := func(id string, ts time.Time, cost *float64, echo bool) *Log {
entry := &Log{
ID: id,
Timestamp: ts,
Object: string(schemas.BatchResultsRequest),
Provider: "anthropic",
Model: "claude-sonnet-4",
Status: "success",
Cost: cost,
BatchDebugParsed: &schemas.BifrostBatchDebug{
BatchID: "batch_1",
Accounting: &schemas.BatchAccountingDebug{
Echo: echo,
ModelBreakdowns: map[string]schemas.BatchModelBreakdown{
"claude-sonnet-4": {Model: "claude-sonnet-4", RequestCount: 1},
},
},
},
}
require.NoError(t, entry.SerializeFields())
return entry
}

unpricedChat := &Log{
ID: "chat-unpriced",
Timestamp: base,
Object: "chat.completion",
Provider: "anthropic",
Model: "claude-sonnet-4",
Status: "success",
}
// An aggregate row that failed to price is exactly what the filter is for.
unpricedAggregate := newBatchRow("batch-aggregate-unpriced", base.Add(time.Minute), nil, false)
pricedAggregate := newBatchRow("batch-aggregate-priced", base.Add(2*time.Minute), &settled, false)
echoRow := newBatchRow("batch-echo", base.Add(3*time.Minute), nil, true)

for _, entry := range []*Log{unpricedChat, unpricedAggregate, pricedAggregate, echoRow} {
require.NoError(t, store.Create(ctx, entry))
}

result, err := store.SearchLogs(ctx, SearchFilters{MissingCostOnly: true}, PaginationOptions{
Limit: 50, SortBy: "timestamp", Order: "asc",
})
require.NoError(t, err)

got := make([]string, 0, len(result.Logs))
for _, l := range result.Logs {
got = append(got, l.ID)
}
require.ElementsMatch(t, []string{"chat-unpriced", "batch-aggregate-unpriced"}, got,
"only rows a recalculation can actually resolve belong in the missing-cost scope")
}
4 changes: 3 additions & 1 deletion framework/logstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,9 @@ func (s *RDBLogStore) applyFilters(baseQuery *gorm.DB, filters SearchFilters) *g
}
if filters.MissingCostOnly {
// cost is null and status is not error
baseQuery = baseQuery.Where("(cost IS NULL OR cost <= 0) AND status NOT IN ('error')")
baseQuery = baseQuery.Where(
"(cost IS NULL OR cost <= 0) AND status NOT IN ('error') AND COALESCE(batch_debug, '') NOT LIKE ?",
"%\"echo\":true%")
}
if len(filters.CacheHitTypes) > 0 {
// Only keep allowed values to avoid passing arbitrary input into the JSON path expression.
Expand Down
51 changes: 51 additions & 0 deletions plugins/governance/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,21 @@ func (p *GovernancePlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.
Model: model,
UserID: userID,
}
// A batch create fans out to many completions, each naming its own model, so
// every model it will run is evaluated before the request itself. Every check
// reached from here is read-only, so the extra passes cannot double-count usage.
if req.RequestType == schemas.BatchCreateRequest && req.BatchCreateRequest != nil && len(req.BatchCreateRequest.Requests) > 0 {
Comment thread
Pratham-Mishra04 marked this conversation as resolved.
for _, batchModel := range BatchCreateModels(req, model) {
batchEvaluationRequest := *evaluationRequest
batchEvaluationRequest.Model = batchModel
_, bifrostError := p.EvaluateGovernanceRequest(ctx, &batchEvaluationRequest, req.RequestType)
if bifrostError != nil {
return req, &schemas.LLMPluginShortCircuit{
Error: bifrostError,
}, nil
}
}
}
// Evaluate governance using common function
_, bifrostError := p.EvaluateGovernanceRequest(ctx, evaluationRequest, req.RequestType)
// Convert BifrostError to LLMPluginShortCircuit if needed
Expand All @@ -1064,6 +1079,42 @@ func (p *GovernancePlugin) PreLLMHook(ctx *schemas.BifrostContext, req *schemas.
return req, nil, nil
}

// BatchCreateModels returns every distinct model an inline batch create will run,
// starting with the request's own model.
func BatchCreateModels(req *schemas.BifrostRequest, model string) []string {
if req.RequestType != schemas.BatchCreateRequest || req.BatchCreateRequest == nil || len(req.BatchCreateRequest.Requests) == 0 {
return []string{model}
}
seen := make(map[string]struct{})
models := make([]string, 0, 1)
add := func(m string) {
if m == "" {
return
}
if _, exists := seen[m]; exists {
return
}
seen[m] = struct{}{}
models = append(models, m)
}
add(model)
for _, item := range req.BatchCreateRequest.Requests {
// Body is the OpenAI shape, Params the Anthropic one; an item carries one.
for _, body := range []map[string]any{item.Body, item.Params} {
if m, ok := body["model"].(string); ok {
add(m)
break
}
}
}
if len(models) == 0 {
// No item named a model: fall back to the model-less evaluation so the
// provider-level and virtual-key checks still run.
return []string{model}
}
return models
}

// PostLLMHook processes the response and updates usage tracking (business logic execution)
// Parameters:
// - ctx: The Bifrost context
Expand Down
4 changes: 1 addition & 3 deletions plugins/governance/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,6 @@ func (r *BudgetResolver) EvaluateTeamRequest(ctx *schemas.BifrostContext, teamID
Decision: DecisionAllow,
Reason: "Team-level checks passed",
}

}

// EvaluateUserRequest evaluates user-level rate limits and budgets (enterprise-only)
Expand Down Expand Up @@ -304,8 +303,7 @@ func (r *BudgetResolver) EvaluateVirtualKeyRequest(ctx *schemas.BifrostContext,
// them. This is separate from skipProviderCheck: a provider the key does configure
// still carries a model allowlist that would deny the request one step later.
skipModelCheck := bifrost.GetBoolFromContext(ctx, schemas.BifrostContextKeySkipModelCheck)
isPassthrough := requestType == schemas.PassthroughRequest || requestType == schemas.PassthroughStreamRequest
checkModelIfPresent := isPassthrough || requestType == schemas.VideoEditRequest
checkModelIfPresent := IsModelCheckedWhenPresent(requestType)
if !skipModelCheck && !providerUnconfigured && (IsModelRequiredForRequest(requestType) || (checkModelIfPresent && model != "")) && !r.isModelAllowed(vk, provider, model) {
return &EvaluationResult{
Decision: DecisionModelBlocked,
Expand Down
9 changes: 6 additions & 3 deletions plugins/governance/resolver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -689,9 +689,12 @@ func TestBudgetResolver_EvaluateRequest_PassthroughModelFiltering(t *testing.T)
{"passthrough stream disallowed model is blocked", "gpt-4o-mini", schemas.PassthroughStreamRequest, DecisionModelBlocked},
{"passthrough stream allowed model passes", "gpt-4", schemas.PassthroughStreamRequest, DecisionAllow},
{"passthrough stream without model has no restriction", "", schemas.PassthroughStreamRequest, DecisionAllow},
// Scoping guard: batch is model-not-required and not passthrough, so its model is never
// filtered even when set to a disallowed value (behavior unchanged by the passthrough fix).
{"batch with disallowed model is not filtered", "gpt-4o-mini", schemas.BatchCreateRequest, DecisionAllow},
// Batch create carries no model of its own for a file-based batch, but an inline
// one names a model per item and governance evaluates each — so the allowlist
// applies whenever a model is actually present.
{"batch with disallowed model is blocked", "gpt-4o-mini", schemas.BatchCreateRequest, DecisionModelBlocked},
{"batch with allowed model passes", "gpt-4", schemas.BatchCreateRequest, DecisionAllow},
{"batch without model has no restriction", "", schemas.BatchCreateRequest, DecisionAllow},
}

for _, tt := range tests {
Expand Down
17 changes: 17 additions & 0 deletions plugins/governance/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ func IsModelRequiredForRequest(requestType schemas.RequestType) bool {
return true
}

// IsModelCheckedWhenPresent reports whether a request type whose model is optional
// should still be checked against the model allowlist when it does carry one.
//
// These are the types IsModelRequiredForRequest excludes because their model may
// legitimately be absent — a file-based batch names none, passthrough forwards raw
// routes, video edit lets the provider infer it. "Optional" must not mean
// "unenforced": when the caller does name a model, the allowlist applies.
func IsModelCheckedWhenPresent(requestType schemas.RequestType) bool {
switch requestType {
case schemas.PassthroughRequest, schemas.PassthroughStreamRequest,
schemas.VideoEditRequest, schemas.BatchCreateRequest:
return true
default:
return false
}
}

// 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:
Expand Down
89 changes: 78 additions & 11 deletions plugins/logging/costfidelity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/bytedance/sonic"
"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/batchaccounting"
"github.com/maximhq/bifrost/framework/logstore"
"github.com/maximhq/bifrost/framework/modelcatalog"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -998,7 +999,7 @@ func TestCalculateBatchAggregateCost_MultiModel(t *testing.T) {
},
}

cost, batchDebugJSON, err := plugin.calculateBatchAggregateCost(entry)
cost, batchDebugJSON, err := plugin.calculateBatchAggregateCost(entry, false)
require.NoError(t, err)
assert.NotEmpty(t, batchDebugJSON)

Expand Down Expand Up @@ -1044,7 +1045,7 @@ func TestCalculateBatchAggregateCost_PartiallyUnpriced(t *testing.T) {
},
}

cost, _, err := plugin.calculateBatchAggregateCost(entry)
cost, _, err := plugin.calculateBatchAggregateCost(entry, false)
require.NoError(t, err)
assertCostsEqual(t, "partially-unpriced batch total", cost, 1000*1.25e-06+200*5e-06)

Expand Down Expand Up @@ -1080,14 +1081,14 @@ func TestCalculateBatchAggregateCost_EmbeddingEndpointUsesEmbeddingRates(t *test
}
}

cost, _, err := plugin.calculateBatchAggregateCost(newEntry(string(schemas.BatchEndpointEmbeddings)))
cost, _, err := plugin.calculateBatchAggregateCost(newEntry(string(schemas.BatchEndpointEmbeddings)), false)
require.NoError(t, err)
// testdata input_cost_per_token_batches for text-embedding-3-small: 1e-08.
assertCostsEqual(t, "embeddings batch total", cost, 1000*1e-08)

// Rows written before the endpoint was persisted keep the old behavior rather
// than being guessed at — for an embedding-only model that still means unpriceable.
_, _, err = plugin.calculateBatchAggregateCost(newEntry(""))
_, _, err = plugin.calculateBatchAggregateCost(newEntry(""), false)
require.ErrorIs(t, err, errPricingInputsUnavailable)
}

Expand All @@ -1114,7 +1115,7 @@ func TestCalculateBatchAggregateCost_PartialRepriceMarksIncomplete(t *testing.T)
},
}

_, batchDebugJSON, err := plugin.calculateBatchAggregateCost(entry)
_, batchDebugJSON, err := plugin.calculateBatchAggregateCost(entry, false)
require.NoError(t, err)
assert.True(t, entry.BatchDebugParsed.Accounting.Incomplete)

Expand Down Expand Up @@ -1148,7 +1149,7 @@ func TestCalculateBatchAggregateCost_IncompleteIsNeverCleared(t *testing.T) {
},
}

_, _, err := plugin.calculateBatchAggregateCost(entry)
_, _, err := plugin.calculateBatchAggregateCost(entry, false)
require.NoError(t, err)
assert.True(t, entry.BatchDebugParsed.Accounting.Incomplete,
"unparseable rows are gone for good; a successful reprice does not recover them")
Expand Down Expand Up @@ -1177,7 +1178,7 @@ func TestCalculateBatchAggregateCost_AllUnpriced(t *testing.T) {
},
}

_, _, err := plugin.calculateBatchAggregateCost(entry)
_, _, err := plugin.calculateBatchAggregateCost(entry, false)
require.ErrorIs(t, err, errPricingInputsUnavailable)
}

Expand All @@ -1191,7 +1192,7 @@ func TestRecalculateCostsReprisesMixedModelBatchRow(t *testing.T) {
store := plugin.store

entry := &logstore.Log{
ID: "batch-e2e-mixed",
ID: batchaccounting.AccountingLogID(schemas.OpenAI, "batch_e2e_mixed"),
Timestamp: time.Now().UTC(),
Object: string(schemas.BatchResultsRequest),
Provider: string(schemas.OpenAI),
Expand All @@ -1214,7 +1215,7 @@ func TestRecalculateCostsReprisesMixedModelBatchRow(t *testing.T) {
require.NoError(t, err)
require.Equal(t, 1, result.Updated)

logged, err := store.FindByID(context.Background(), "batch-e2e-mixed")
logged, err := store.FindByID(context.Background(), batchaccounting.AccountingLogID(schemas.OpenAI, "batch_e2e_mixed"))
require.NoError(t, err)
require.NotNil(t, logged.Cost)

Expand Down Expand Up @@ -1246,7 +1247,7 @@ func TestRunCostRecalcJobDoesNotZeroPricedBatchRow(t *testing.T) {
settledCost := wantGPT4o + wantGPT4oMini

entry := &logstore.Log{
ID: "batch-job-priced",
ID: batchaccounting.AccountingLogID(schemas.OpenAI, "batch_job_priced"),
Timestamp: time.Now().UTC(),
Object: string(schemas.BatchResultsRequest),
Provider: string(schemas.OpenAI),
Expand Down Expand Up @@ -1277,7 +1278,7 @@ func TestRunCostRecalcJobDoesNotZeroPricedBatchRow(t *testing.T) {
require.NoError(t, sonic.Unmarshal([]byte(finalJSON), &meta))
require.Equal(t, 1, meta.Updated)

logged, err := store.FindByID(ctx, "batch-job-priced")
logged, err := store.FindByID(ctx, batchaccounting.AccountingLogID(schemas.OpenAI, "batch_job_priced"))
require.NoError(t, err)
require.NotNil(t, logged.Cost)
assertCostsEqual(t, "batch row cost after background recalculation", *logged.Cost, settledCost)
Expand All @@ -1291,3 +1292,69 @@ func TestRunCostRecalcJobDoesNotZeroPricedBatchRow(t *testing.T) {
require.NotNil(t, breakdowns["gpt-4o-mini"].Cost)
assertCostsEqual(t, "gpt-4o-mini breakdown cost", *breakdowns["gpt-4o-mini"].Cost, wantGPT4oMini)
}

// TestRecalculateCostsDoesNotBillBatchEchoRow is the regression test for a
// recalculation turning one batch into N charges. A repeated /results fetch gets
// its own log row carrying the settled breakdowns and a snapshot cost, and it is
// indistinguishable from the settlement row by object type — so repricing wrote a
// full copy of the batch bill onto every one of them. The echo row must keep a
// NULL cost (it contributes to no total) while the price it displays is refreshed.
func TestRecalculateCostsDoesNotBillBatchEchoRow(t *testing.T) {
plugin := newCostFidelityPlugin(t)
store := plugin.store
ctx := context.Background()

const batchID = "batch_echo"
aggregateID := batchaccounting.AccountingLogID(schemas.OpenAI, batchID)
staleCost := 42.0
now := time.Now().UTC()

breakdowns := func() map[string]schemas.BatchModelBreakdown {
return map[string]schemas.BatchModelBreakdown{"gpt-4o": batchModelBreakdown(1000, 200)}
}
wantCost := 1000*1.25e-06 + 200*5e-06

aggregate := &logstore.Log{
ID: aggregateID,
Timestamp: now,
Object: string(schemas.BatchResultsRequest),
Provider: string(schemas.OpenAI),
Model: "gpt-4o",
Status: "success",
BatchDebugParsed: &schemas.BifrostBatchDebug{
BatchID: batchID,
Accounting: &schemas.BatchAccountingDebug{ModelBreakdowns: breakdowns()},
},
}
echo := &logstore.Log{
ID: "echo-results-fetch",
Timestamp: now.Add(time.Minute),
Object: string(schemas.BatchResultsRequest),
Provider: string(schemas.OpenAI),
Model: "gpt-4o",
Status: "success",
BatchDebugParsed: &schemas.BifrostBatchDebug{
BatchID: batchID,
Accounting: &schemas.BatchAccountingDebug{ModelBreakdowns: breakdowns(), Cost: &staleCost},
},
}
require.NoError(t, store.Create(ctx, aggregate))
require.NoError(t, store.Create(ctx, echo))

_, err := plugin.RecalculateCosts(ctx, logstore.SearchFilters{}, 10)
require.NoError(t, err)

settled, err := store.FindByID(ctx, aggregateID)
require.NoError(t, err)
require.NotNil(t, settled.Cost, "the settlement row owns the bill")
assertCostsEqual(t, "aggregate row cost", *settled.Cost, wantCost)

fetched, err := store.FindByID(ctx, "echo-results-fetch")
require.NoError(t, err)
require.Nil(t, fetched.Cost, "an echo row must never be billed")
require.NotNil(t, fetched.BatchDebugParsed)
require.NotNil(t, fetched.BatchDebugParsed.Accounting)
require.NotNil(t, fetched.BatchDebugParsed.Accounting.Cost)
assertCostsEqual(t, "echo row displayed cost", *fetched.BatchDebugParsed.Accounting.Cost, wantCost)
require.NotNil(t, fetched.BatchDebugParsed.Accounting.ModelBreakdowns["gpt-4o"].Cost)
}
Loading
Loading