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
19 changes: 19 additions & 0 deletions core/schemas/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -1728,6 +1728,7 @@ type BifrostResponseExtraFields struct {
RawRequest interface{} `json:"raw_request,omitempty"`
RawResponse interface{} `json:"raw_response,omitempty"`
CacheDebug *BifrostCacheDebug `json:"cache_debug,omitempty"`
RoutingDebug *BifrostRoutingDebug `json:"routing_debug,omitempty"`
GuardrailDebug *BifrostGuardrailDebug `json:"guardrail_debug,omitempty"`
ParseErrors []BatchError `json:"parse_errors,omitempty"` // errors encountered while parsing JSONL batch results
ConvertedRequestType RequestType `json:"converted_request_type,omitempty"`
Expand Down Expand Up @@ -1805,6 +1806,24 @@ type BifrostCacheDebug struct {
CacheHitLatency *int64 `json:"cache_hit_latency,omitempty"`
}

// BifrostRoutingDebug records routing-classification overhead attached to the
// triggering request — today the embedding call semantic complexity routing
// makes before provider selection. It is stamped whenever a routing embedding
// ran, independent of budget attribution, so routing cost stays observable.
// Routing-mechanism fields (tier, mechanism, similarity) may be added here as
// classification logging grows.
type BifrostRoutingDebug struct {
ProviderUsed *string `json:"provider_used,omitempty"`
ModelUsed *string `json:"model_used,omitempty"`
InputTokens *int `json:"input_tokens,omitempty"`
Comment thread
kohlivrinda marked this conversation as resolved.

// CountTowardBudgets carries the governance count_toward_budgets flag to
// cost calculation, which cannot see governance config. When true, the
// routing embedding cost is added to the request's calculated cost (and so
// to its budget attribution); it is never budget-enforced.
CountTowardBudgets bool `json:"count_toward_budgets,omitempty"`
}

const (
RequestCancelled = "request_cancelled"
RequestTimedOut = "request_timed_out"
Expand Down
62 changes: 51 additions & 11 deletions framework/modelcatalog/datasheet/cost.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import (
)

// CalculateCost calculates the cost of a Bifrost response.
// It handles all request types, cache and guardrail billing, and tiered pricing.
// It handles all request types, cache, guardrail, and routing billing, and tiered pricing.
// If scopes is nil, an empty LookupScopes is used; global and provider-scoped
// overrides may still apply since the provider is derived from the response.
func (s *Store) CalculateCost(result *schemas.BifrostResponse, scopes *LookupScopes) float64 {
Expand All @@ -26,20 +26,60 @@ func (s *Store) CalculateCost(result *schemas.BifrostResponse, scopes *LookupSco

extraFields := result.GetExtraFields()

// Handle semantic cache billing
cacheDebug := extraFields.CacheDebug
var requestCost float64
if cacheDebug != nil {
requestCost = s.calculateCostWithCache(result, cacheDebug, lookupScopes)
// The main request and each internal sidecar are independently billable.
// Keep a single accumulator so cache, guardrail, and routing debug metadata
// can coexist without one branch hiding another.
var cost float64
if extraFields != nil && extraFields.CacheDebug != nil {
cost = s.calculateCostWithCache(result, extraFields.CacheDebug, lookupScopes)
} else {
requestCost = s.calculateBaseCost(result, lookupScopes)
cost = s.calculateBaseCost(result, lookupScopes)
}

if extraFields != nil && extraFields.GuardrailDebug != nil {
cost += s.CalculateGuardrailCost(extraFields.GuardrailDebug, &lookupScopes)
}

// Routing-classification embedding cost is budget-attributed only when
// governance explicitly opted the request in. Telemetry can still price it
// independently through RoutingEmbeddingCost.
if extraFields != nil && extraFields.RoutingDebug != nil && extraFields.RoutingDebug.CountTowardBudgets {
cost += s.RoutingEmbeddingCost(extraFields.RoutingDebug, &lookupScopes)
}

return cost
}

// RoutingEmbeddingCost calculates the embedding cost of a semantic routing
// classification from its RoutingDebug stamp. Exported so telemetry can price
// routing overhead unconditionally, while CalculateCost folds it into the
// request cost only when RoutingDebug.CountTowardBudgets is set. If scopes is
// nil, an empty LookupScopes is used.
func (s *Store) RoutingEmbeddingCost(routingDebug *schemas.BifrostRoutingDebug, scopes *LookupScopes) float64 {
if routingDebug == nil || routingDebug.ProviderUsed == nil || routingDebug.ModelUsed == nil || routingDebug.InputTokens == nil {
return 0
}
// Malformed usage must never create a negative sidecar cost that subtracts
// from the request's budget attribution.
if *routingDebug.InputTokens < 0 {
return 0
}

// Handle guardrail judge-call billing
if extraFields.GuardrailDebug == nil {
return requestCost
var lookupScopes LookupScopes
if scopes != nil {
lookupScopes = *scopes
}
// The embedding can use a different provider from the main request, so its
// provider-scoped overrides must be resolved against the embedding provider.
lookupScopes.Provider = *routingDebug.ProviderUsed
pricing := s.resolvePricing(schemas.RoutingInfo{
Provider: schemas.ModelProvider(*routingDebug.ProviderUsed),
Model: *routingDebug.ModelUsed,
}, schemas.EmbeddingRequest, lookupScopes)
if pricing == nil {
return 0
}
return requestCost + s.CalculateGuardrailCost(extraFields.GuardrailDebug, &lookupScopes)
return float64(*routingDebug.InputTokens) * tieredInputRate(pricing, *routingDebug.InputTokens, serviceTier{})
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// CalculateCostForUsage computes the dollar cost from a bare usage object plus
Expand Down
150 changes: 150 additions & 0 deletions framework/modelcatalog/datasheet/cost_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1854,6 +1854,156 @@ func TestCalculateGuardrailCostUsesJudgeProviderWithoutCallerSelectedKey(t *test
assert.InDelta(t, 30.0, cost, 1e-12)
}

// =========================================================================
// 10b. Semantic routing billing (RoutingDebug)
// =========================================================================

// routingDebugFor builds a RoutingDebug stamp for the given embedding call.
func routingDebugFor(provider, model string, inputTokens int, countTowardBudgets bool) *schemas.BifrostRoutingDebug {
return &schemas.BifrostRoutingDebug{
ProviderUsed: &provider,
ModelUsed: &model,
InputTokens: &inputTokens,
CountTowardBudgets: countTowardBudgets,
}
}

// routingBillingTestStore has chat pricing for the request model and embedding
// pricing for the routing classifier's model.
func routingBillingTestStore() *Store {
return testStoreWithPricing(map[string]configstoreTables.TableModelPricing{
makeKey("gpt-4o", "openai", "chat"): {
Model: "gpt-4o", Provider: "openai", Mode: "chat",
InputCostPerToken: bifrost.Ptr(0.000005), OutputCostPerToken: bifrost.Ptr(0.000015),
},
makeKey("text-embedding-3-small", "openai", "embedding"): {
Model: "text-embedding-3-small", Provider: "openai", Mode: "embedding",
InputCostPerToken: bifrost.Ptr(0.00000002),
},
})
}

func TestCalculateCost_RoutingDebugFlagOff(t *testing.T) {
s := routingBillingTestStore()

resp := &schemas.BifrostResponse{
ChatResponse: &schemas.BifrostChatResponse{
Usage: &schemas.BifrostLLMUsage{PromptTokens: 1000, CompletionTokens: 500, TotalTokens: 1500},
ExtraFields: schemas.BifrostResponseExtraFields{
RequestType: schemas.ChatCompletionRequest,
RoutingInfo: routingInfoFor(schemas.OpenAI, "gpt-4o"),
RoutingDebug: routingDebugFor("openai", "text-embedding-3-small", 500, false),
},
},
}

// count_toward_budgets off: base cost only, routing embed adds zero.
cost := s.CalculateCost(resp, nil)
assert.InDelta(t, 0.0125, cost, 1e-12)
}

func TestCalculateCost_RoutingDebugFlagOn(t *testing.T) {
s := routingBillingTestStore()

resp := &schemas.BifrostResponse{
ChatResponse: &schemas.BifrostChatResponse{
Usage: &schemas.BifrostLLMUsage{PromptTokens: 1000, CompletionTokens: 500, TotalTokens: 1500},
ExtraFields: schemas.BifrostResponseExtraFields{
RequestType: schemas.ChatCompletionRequest,
RoutingInfo: routingInfoFor(schemas.OpenAI, "gpt-4o"),
RoutingDebug: routingDebugFor("openai", "text-embedding-3-small", 500, true),
},
},
}

// Base cost: 1000*0.000005 + 500*0.000015 = 0.0125
// Routing embedding cost: 500 * 0.00000002 = 0.00001
cost := s.CalculateCost(resp, nil)
assert.InDelta(t, 0.01251, cost, 1e-12)
}

func TestCalculateCost_RoutingDebugComposesWithCacheDebug(t *testing.T) {
embProvider := "openai"
embModel := "text-embedding-3-small"
embTokens := 500

s := routingBillingTestStore()

resp := &schemas.BifrostResponse{
ChatResponse: &schemas.BifrostChatResponse{
Usage: &schemas.BifrostLLMUsage{PromptTokens: 1000, CompletionTokens: 500, TotalTokens: 1500},
ExtraFields: schemas.BifrostResponseExtraFields{
RequestType: schemas.ChatCompletionRequest,
RoutingInfo: routingInfoFor(schemas.OpenAI, "gpt-4o"),
CacheDebug: &schemas.BifrostCacheDebug{
CacheHit: false,
ProviderUsed: &embProvider,
ModelUsed: &embModel,
InputTokens: &embTokens,
},
RoutingDebug: routingDebugFor("openai", "text-embedding-3-small", 250, true),
},
},
}

// Base cost: 0.0125
// Cache embedding cost: 500 * 0.00000002 = 0.00001
// Routing embedding cost: 250 * 0.00000002 = 0.000005
cost := s.CalculateCost(resp, nil)
assert.InDelta(t, 0.012515, cost, 1e-12)
}

func TestRoutingEmbeddingCost_Standalone(t *testing.T) {
s := routingBillingTestStore()

// Priced regardless of the CountTowardBudgets flag — telemetry uses this
// to report routing overhead unconditionally.
cost := s.RoutingEmbeddingCost(routingDebugFor("openai", "text-embedding-3-small", 500, false), nil)
assert.InDelta(t, 0.00001, cost, 1e-12)
}

func TestRoutingEmbeddingCost_MissingFields(t *testing.T) {
s := routingBillingTestStore()

assert.Equal(t, 0.0, s.RoutingEmbeddingCost(nil, nil))
assert.Equal(t, 0.0, s.RoutingEmbeddingCost(&schemas.BifrostRoutingDebug{}, nil))

provider := "openai"
model := "text-embedding-3-small"
assert.Equal(t, 0.0, s.RoutingEmbeddingCost(&schemas.BifrostRoutingDebug{
ProviderUsed: &provider,
ModelUsed: &model,
// InputTokens missing
}, nil))
}

// A malformed provider usage payload must never produce a negative routing
// cost: that would subtract from the request's billed cost and under-count its
// budget attribution.
func TestRoutingEmbeddingCost_NegativeInputTokensRejected(t *testing.T) {
s := routingBillingTestStore()

assert.Equal(t, 0.0, s.RoutingEmbeddingCost(routingDebugFor("openai", "text-embedding-3-small", -500, true), nil))

resp := &schemas.BifrostResponse{
ChatResponse: &schemas.BifrostChatResponse{
Usage: &schemas.BifrostLLMUsage{PromptTokens: 1000, CompletionTokens: 500, TotalTokens: 1500},
ExtraFields: schemas.BifrostResponseExtraFields{
RequestType: schemas.ChatCompletionRequest,
RoutingInfo: routingInfoFor(schemas.OpenAI, "gpt-4o"),
RoutingDebug: routingDebugFor("openai", "text-embedding-3-small", -500, true),
},
},
}

// Base cost only — the negative routing embed contributes nothing.
assert.InDelta(t, 0.0125, s.CalculateCost(resp, nil), 1e-12)

// Zero stays valid and free; positive counts are unaffected.
assert.Equal(t, 0.0, s.RoutingEmbeddingCost(routingDebugFor("openai", "text-embedding-3-small", 0, true), nil))
assert.InDelta(t, 0.00001, s.RoutingEmbeddingCost(routingDebugFor("openai", "text-embedding-3-small", 500, true), nil), 1e-12)
}

// =========================================================================
// 11. CalculateCost integration — end-to-end
// =========================================================================
Expand Down
9 changes: 9 additions & 0 deletions framework/modelcatalog/pricing.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,15 @@ func (mc *ModelCatalog) CalculateCostForUsage(usage *schemas.BifrostLLMUsage, pr
return mc.datasheet.CalculateCostForUsage(usage, provider, model, requestType, (*datasheet.LookupScopes)(scopes))
}

// CalculateRoutingEmbeddingCost prices the embedding call recorded in a
// response's RoutingDebug stamp, independent of its CountTowardBudgets flag —
// telemetry uses it to report routing overhead unconditionally, while
// CalculateCost folds the same amount into the request's cost only when the
// flag is set.
func (mc *ModelCatalog) CalculateRoutingEmbeddingCost(routingDebug *schemas.BifrostRoutingDebug, scopes *PricingLookupScopes) float64 {
return mc.datasheet.RoutingEmbeddingCost(routingDebug, (*datasheet.LookupScopes)(scopes))
}

// CalculateGuardrailCost computes the aggregate cost of guardrail judge calls.
func (mc *ModelCatalog) CalculateGuardrailCost(debug *schemas.BifrostGuardrailDebug, scopes *PricingLookupScopes) float64 {
return mc.datasheet.CalculateGuardrailCost(debug, (*datasheet.LookupScopes)(scopes))
Expand Down
Loading
Loading