feat(billing): highlight matched conditional multipliers in logs (#6561)

* feat(billing): highlight matched conditional multipliers in usage logs

* fix(billing): make request rule tracing stable and type-safe
This commit is contained in:
Seefs
2026-08-10 12:50:27 +08:00
committed by GitHub
parent d49160f0e5
commit 4cf9107f04
12 changed files with 400 additions and 110 deletions
+63 -10
View File
@@ -5,6 +5,8 @@ import (
"testing"
"github.com/QuantumNous/new-api/pkg/billingexpr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
@@ -228,10 +230,11 @@ func TestRequestProbeMissingFieldReturnsNil(t *testing.T) {
}
}
func TestRequestProbeMultipleRulesMultiply(t *testing.T) {
cost, _, err := billingexpr.RunExprWithRequest(
`(param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode-2026-02-01") ? 2.5 : 1)`,
billingexpr.TokenParams{},
func TestRequestProbeMultipleRulesTraceAllFactors(t *testing.T) {
exprStr := `(tier("base", p * 2)) * (param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode-2026-02-01") ? 2.5 : 1)`
cost, trace, err := billingexpr.RunExprWithRequest(
exprStr,
billingexpr.TokenParams{P: 10},
billingexpr.RequestInput{
Headers: map[string]string{
"Anthropic-Beta": "fast-mode-2026-02-01",
@@ -239,12 +242,62 @@ func TestRequestProbeMultipleRulesMultiply(t *testing.T) {
Body: []byte(`{"service_tier":"fast"}`),
},
)
if err != nil {
t.Fatal(err)
}
if math.Abs(cost-5) > 1e-6 {
t.Errorf("cost = %f, want 5", cost)
}
require.NoError(t, err)
assert.InDelta(t, 100, cost, 1e-6)
assert.Equal(t, "base", trace.MatchedTier)
assert.Equal(t, []billingexpr.RequestRuleTrace{
{Cond: `param("service_tier") == "fast"`, Multiplier: 2, Matched: true},
{Cond: `has(header("anthropic-beta"), "fast-mode-2026-02-01")`, Multiplier: 2.5, Matched: true},
}, trace.RequestRules)
}
func TestRequestProbeTraceIncludesUnmatchedFactors(t *testing.T) {
exprStr := `(tier("base", p * 2)) * (param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode") ? 2.5 : 1)`
cost, trace, err := billingexpr.RunExprWithRequest(
exprStr,
billingexpr.TokenParams{P: 10},
billingexpr.RequestInput{Body: []byte(`{"service_tier":"fast"}`)},
)
require.NoError(t, err)
assert.InDelta(t, 40, cost, 1e-6)
assert.Equal(t, []billingexpr.RequestRuleTrace{
{Cond: `param("service_tier") == "fast"`, Multiplier: 2, Matched: true},
{Cond: `has(header("anthropic-beta"), "fast-mode")`, Multiplier: 2.5, Matched: false},
}, trace.RequestRules)
}
func TestRequestProbeTracePreservesIntegerConditionalType(t *testing.T) {
cost, trace, err := billingexpr.RunExprWithRequest(
`5 % (param("service_tier") == "fast" ? 2 : 1)`,
billingexpr.TokenParams{},
billingexpr.RequestInput{Body: []byte(`{"service_tier":"fast"}`)},
)
require.NoError(t, err)
assert.Equal(t, float64(1), cost)
assert.Equal(t, []billingexpr.RequestRuleTrace{
{Cond: `param("service_tier") == "fast"`, Multiplier: 2, Matched: true},
}, trace.RequestRules)
}
func TestRequestProbeNonUnitFallbackIsNotTraced(t *testing.T) {
cost, trace, err := billingexpr.RunExprWithRequest(
`10 * (param("service_tier") == "fast" ? 2 : 1.5)`,
billingexpr.TokenParams{},
billingexpr.RequestInput{Body: []byte(`{"service_tier":"standard"}`)},
)
require.NoError(t, err)
assert.InDelta(t, 15, cost, 1e-6)
assert.Empty(t, trace.RequestRules)
}
func TestRequestProbeInternalTraceFunctionIsReserved(t *testing.T) {
_, err := billingexpr.CompileFromCache(`_trace(0, true, 5.0)`)
require.ErrorContains(t, err, `identifier "_trace" is reserved for internal use`)
}
func TestCeilFloor(t *testing.T) {
+138 -33
View File
@@ -16,6 +16,11 @@ const maxCacheSize = 256
// DefaultExprVersion is used when an expression string has no version prefix.
const DefaultExprVersion = 1
const (
requestRuleTraceFunction = "_trace"
requestRuleTraceIntFunction = "_trace_int"
)
// ParseExprVersion extracts the version tag and body from an expression string.
// Format: "v1:tier(...)" → version=1, body="tier(...)".
// No prefix defaults to DefaultExprVersion.
@@ -26,10 +31,88 @@ func ParseExprVersion(exprStr string) (version int, body string) {
return DefaultExprVersion, exprStr
}
// requestRulePatcher adds trace side effects to existing request multipliers
// without changing the stored expression or its numeric result.
type requestRulePatcher struct {
requestRules []RequestRuleTrace
restrictedIdentifier string
}
func (p *requestRulePatcher) Visit(node *ast.Node) {
if identifier, ok := (*node).(*ast.IdentifierNode); ok {
switch identifier.Value {
case requestRuleTraceFunction, requestRuleTraceIntFunction:
p.restrictedIdentifier = identifier.Value
}
return
}
conditional, ok := (*node).(*ast.ConditionalNode)
if !ok || !conditional.Ternary || !usesRequestProbe(conditional.Cond) {
return
}
multiplier, ok := requestRuleNumber(conditional.Exp1)
fallback, fallbackOK := requestRuleNumber(conditional.Exp2)
if !ok || !fallbackOK || fallback != 1 {
return
}
ruleIndex := len(p.requestRules)
p.requestRules = append(p.requestRules, RequestRuleTrace{
Cond: conditional.Cond.String(),
Multiplier: multiplier,
})
traceFunction := requestRuleTraceFunction
var multiplierNode ast.Node = &ast.FloatNode{Value: multiplier}
if _, multiplierIsInt := conditional.Exp1.(*ast.IntegerNode); multiplierIsInt {
if _, fallbackIsInt := conditional.Exp2.(*ast.IntegerNode); fallbackIsInt {
traceFunction = requestRuleTraceIntFunction
multiplierNode = conditional.Exp1
}
}
ast.Patch(node, &ast.CallNode{
Callee: &ast.IdentifierNode{Value: traceFunction},
Arguments: []ast.Node{
&ast.IntegerNode{Value: ruleIndex},
conditional.Cond,
multiplierNode,
},
})
}
func requestRuleNumber(node ast.Node) (float64, bool) {
switch value := node.(type) {
case *ast.IntegerNode:
return float64(value.Value), true
case *ast.FloatNode:
return value.Value, true
default:
return 0, false
}
}
func usesRequestProbe(node ast.Node) bool {
return ast.Find(node, func(node ast.Node) bool {
identifier, ok := node.(*ast.IdentifierNode)
if !ok {
return false
}
switch identifier.Value {
case "param", "header", "hour", "minute", "weekday", "month", "day":
return true
default:
return false
}
}) != nil
}
type cachedEntry struct {
prog *vm.Program
usedVars map[string]bool
version int
prog *vm.Program
usedVars map[string]bool
requestRules []RequestRuleTrace
version int
}
var (
@@ -39,30 +122,32 @@ var (
// compileEnvPrototypeV1 is the v1 type-checking prototype used at compile time.
var compileEnvPrototypeV1 = map[string]interface{}{
"p": float64(0),
"c": float64(0),
"len": float64(0),
"cr": float64(0),
"cc": float64(0),
"cc1h": float64(0),
"img": float64(0),
"img_o": float64(0),
"ai": float64(0),
"ao": float64(0),
"tier": func(string, float64) float64 { return 0 },
"header": func(string) string { return "" },
"param": func(string) interface{} { return nil },
"has": func(interface{}, string) bool { return false },
"hour": func(string) int { return 0 },
"minute": func(string) int { return 0 },
"weekday": func(string) int { return 0 },
"month": func(string) int { return 0 },
"day": func(string) int { return 0 },
"max": math.Max,
"min": math.Min,
"abs": math.Abs,
"ceil": math.Ceil,
"floor": math.Floor,
"p": float64(0),
"c": float64(0),
"len": float64(0),
"cr": float64(0),
"cc": float64(0),
"cc1h": float64(0),
"img": float64(0),
"img_o": float64(0),
"ai": float64(0),
"ao": float64(0),
"tier": func(string, float64) float64 { return 0 },
"_trace": func(int, bool, float64) float64 { return 1 },
"_trace_int": func(int, bool, int) int { return 1 },
"header": func(string) string { return "" },
"param": func(string) interface{} { return nil },
"has": func(interface{}, string) bool { return false },
"hour": func(string) int { return 0 },
"minute": func(string) int { return 0 },
"weekday": func(string) int { return 0 },
"month": func(string) int { return 0 },
"day": func(string) int { return 0 },
"max": math.Max,
"min": math.Min,
"abs": math.Abs,
"ceil": math.Ceil,
"floor": math.Floor,
}
func getCompileEnv(version int) map[string]interface{} {
@@ -85,29 +170,45 @@ func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
}
func compileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
entry, err := compileEntryFromCacheByHash(exprStr, hash)
if err != nil {
return nil, err
}
return entry.prog, nil
}
func compileEntryFromCacheByHash(exprStr, hash string) (*cachedEntry, error) {
cacheMu.RLock()
if entry, ok := cache[hash]; ok {
cacheMu.RUnlock()
return entry.prog, nil
return entry, nil
}
cacheMu.RUnlock()
version, body := ParseExprVersion(exprStr)
prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.AsFloat64())
patcher := &requestRulePatcher{}
prog, err := expr.Compile(body, expr.Env(getCompileEnv(version)), expr.Patch(patcher), expr.AsFloat64())
if patcher.restrictedIdentifier != "" {
return nil, fmt.Errorf("expr compile error: identifier %q is reserved for internal use", patcher.restrictedIdentifier)
}
if err != nil {
return nil, fmt.Errorf("expr compile error: %w", err)
}
vars := extractUsedVars(prog)
entry := &cachedEntry{
prog: prog,
usedVars: extractUsedVars(prog),
requestRules: patcher.requestRules,
version: version,
}
cacheMu.Lock()
if len(cache) >= maxCacheSize {
cache = make(map[string]*cachedEntry, 64)
}
cache[hash] = &cachedEntry{prog: prog, usedVars: vars, version: version}
cache[hash] = entry
cacheMu.Unlock()
return prog, nil
return entry, nil
}
// ExprVersion returns the version of a cached expression. Returns DefaultExprVersion
@@ -132,6 +233,10 @@ func extractUsedVars(prog *vm.Program) map[string]bool {
node := prog.Node()
ast.Find(node, func(n ast.Node) bool {
if id, ok := n.(*ast.IdentifierNode); ok {
switch id.Value {
case requestRuleTraceFunction, requestRuleTraceIntFunction:
return false
}
vars[id.Value] = true
}
return false
+27 -3
View File
@@ -116,7 +116,31 @@ Request-conditional multipliers are appended to the expression after a `|||` sep
tier("base", p * 5 + c * 25)|||when(header("anthropic-beta") has "fast-mode") * 6
```
These are parsed and applied separately by the request rule system.
These factors are stored as ordinary multiplication in the final expression (for example, `(tier(...)) * (condition ? 6 : 1)`) and run in the same billing program.
### Request Rule Tracing
At compile time, the engine instruments ternary factors with this exact shape:
```
<request-probe condition> ? <numeric literal> : 1
```
The condition must reference at least one request probe (`param`, `header`, `hour`, `minute`, `weekday`, `month`, or `day`). Both branches must be numeric literals and the fallback must equal `1`. Other conditionals, including `(condition ? 2 : 1.5)`, are evaluated normally but are not traced. Integer-only factors use an integer-preserving trace callback, so instrumentation does not change expressions that require an integer operand (for example, `%`). The internal trace callback names are reserved and cannot be used in stored expressions.
The compiled cache stores the canonical condition and multiplier for every instrumented node. Each run starts with the full detected rule list marked as unmatched; callbacks mark rules that actually evaluate true. Rules skipped by normal expression short-circuiting remain unmatched. This keeps the expression's numeric result unchanged and avoids reparsing it on each request.
Settlement copies the actual run's traces into the consume log as:
```json
{
"request_rules": [
{ "cond": "param(\"service_tier\") == \"fast\"", "multiplier": 2, "matched": true }
]
}
```
The usage-log UI treats `request_rules` as the authoritative rule list and renders directly from it. It parses `cond` only to produce a friendly label and falls back to the canonical condition text when that parser does not recognize the condition. Pricing pages without log context continue to parse the stored expression for display.
---
@@ -182,9 +206,9 @@ After the upstream response returns with actual token usage:
**Files**: `service/log_info_generate.go`, `web/src/helpers/render.jsx`
Backend: `InjectTieredBillingInfo()` adds `billing_mode`, `expr_b64` (base64 expression), and `matched_tier` to the log's `other` JSON.
Backend: `InjectTieredBillingInfo()` adds `billing_mode`, `expr_b64` (base64 expression), `matched_tier`, and the structured `request_rules` trace list to the log's `other` JSON.
Frontend: Detects `billing_mode === "tiered_expr"`, decodes `expr_b64`, parses tiers via shared `parseTiersFromExpr()`, and renders pricing breakdown.
Frontend: Detects `billing_mode === "tiered_expr"`, decodes `expr_b64`, parses tiers via shared `parseTiersFromExpr()`, and renders request multipliers from `request_rules` when present. Without log traces, it falls back to parsing the stored expression.
---
+26 -6
View File
@@ -26,11 +26,11 @@ func RunExpr(exprStr string, params TokenParams) (float64, TraceResult, error) {
}
func RunExprWithRequest(exprStr string, params TokenParams, request RequestInput) (float64, TraceResult, error) {
prog, err := CompileFromCache(exprStr)
entry, err := compileEntryFromCacheByHash(exprStr, ExprHashString(exprStr))
if err != nil {
return 0, TraceResult{}, err
}
return runProgram(prog, params, request)
return runProgram(entry.prog, entry.requestRules, params, request)
}
// RunExprByHash is like RunExpr but accepts a pre-computed hash for the cache
@@ -41,15 +41,17 @@ func RunExprByHash(exprStr, hash string, params TokenParams) (float64, TraceResu
}
func RunExprByHashWithRequest(exprStr, hash string, params TokenParams, request RequestInput) (float64, TraceResult, error) {
prog, err := CompileFromCacheByHash(exprStr, hash)
entry, err := compileEntryFromCacheByHash(exprStr, hash)
if err != nil {
return 0, TraceResult{}, err
}
return runProgram(prog, params, request)
return runProgram(entry.prog, entry.requestRules, params, request)
}
func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (float64, TraceResult, error) {
trace := TraceResult{}
func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, params TokenParams, request RequestInput) (float64, TraceResult, error) {
trace := TraceResult{
RequestRules: append([]RequestRuleTrace(nil), requestRules...),
}
headers := normalizeHeaders(request.Headers)
env := map[string]interface{}{
@@ -68,6 +70,24 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo
trace.Cost = value
return value
},
requestRuleTraceFunction: func(ruleIndex int, matched bool, multiplier float64) float64 {
if matched && ruleIndex >= 0 && ruleIndex < len(trace.RequestRules) {
trace.RequestRules[ruleIndex].Matched = true
}
if matched {
return multiplier
}
return 1
},
requestRuleTraceIntFunction: func(ruleIndex int, matched bool, multiplier int) int {
if matched && ruleIndex >= 0 && ruleIndex < len(trace.RequestRules) {
trace.RequestRules[ruleIndex].Matched = true
}
if matched {
return multiplier
}
return 1
},
"header": func(key string) string {
return headers[strings.ToLower(strings.TrimSpace(key))]
},
+1
View File
@@ -32,6 +32,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
ActualQuotaBeforeGroup: quotaBeforeGroup,
ActualQuotaAfterGroup: afterGroup,
MatchedTier: trace.MatchedTier,
RequestRules: trace.RequestRules,
CrossedTier: crossed,
Clamp: clamp,
}, nil
+16 -9
View File
@@ -28,12 +28,18 @@ type TokenParams struct {
AO float64 // audio output tokens
}
// TraceResult holds side-channel info captured by the tier() function
// during Expr execution. This replaces the old Breakdown mechanism —
// the Expr itself is the single source of truth for billing logic.
// RequestRuleTrace describes one request-dependent multiplier detected at compile time.
type RequestRuleTrace struct {
Cond string `json:"cond"`
Multiplier float64 `json:"multiplier"`
Matched bool `json:"matched"`
}
// TraceResult holds side-channel info captured while an expression runs.
type TraceResult struct {
MatchedTier string `json:"matched_tier"`
Cost float64 `json:"cost"`
MatchedTier string `json:"matched_tier"`
RequestRules []RequestRuleTrace `json:"request_rules,omitempty"`
Cost float64 `json:"cost"`
}
// BillingSnapshot captures billing state at pre-consume time. Expression and
@@ -57,10 +63,11 @@ type BillingSnapshot struct {
// TieredResult holds everything needed after running tiered settlement.
type TieredResult struct {
ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"`
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
MatchedTier string `json:"matched_tier"`
CrossedTier bool `json:"crossed_tier"`
ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"`
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
MatchedTier string `json:"matched_tier"`
RequestRules []RequestRuleTrace `json:"request_rules,omitempty"`
CrossedTier bool `json:"crossed_tier"`
// Clamp records an int32 saturation event during quota conversion so the
// caller can surface it on the consume log for admin auditing. Nil when no
// clamping occurred. Not serialized: the marker is attached separately via