mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-13 07:40:56 +00:00
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:
@@ -265,12 +265,19 @@ func TestBuildTestLogOtherInjectsTieredInfo(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
requestRules := []billingexpr.RequestRuleTrace{{
|
||||||
|
Cond: `param("service_tier") == "fast"`,
|
||||||
|
Multiplier: 2,
|
||||||
|
Matched: true,
|
||||||
|
}}
|
||||||
other := buildTestLogOther(ctx, info, priceData, usage, &billingexpr.TieredResult{
|
other := buildTestLogOther(ctx, info, priceData, usage, &billingexpr.TieredResult{
|
||||||
MatchedTier: "base",
|
MatchedTier: "base",
|
||||||
|
RequestRules: requestRules,
|
||||||
})
|
})
|
||||||
|
|
||||||
require.Equal(t, "tiered_expr", other["billing_mode"])
|
require.Equal(t, "tiered_expr", other["billing_mode"])
|
||||||
require.Equal(t, "base", other["matched_tier"])
|
require.Equal(t, "base", other["matched_tier"])
|
||||||
|
require.Equal(t, requestRules, other["request_rules"])
|
||||||
require.NotEmpty(t, other["expr_b64"])
|
require.NotEmpty(t, other["expr_b64"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/pkg/billingexpr"
|
"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) {
|
func TestRequestProbeMultipleRulesTraceAllFactors(t *testing.T) {
|
||||||
cost, _, err := billingexpr.RunExprWithRequest(
|
exprStr := `(tier("base", p * 2)) * (param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode-2026-02-01") ? 2.5 : 1)`
|
||||||
`(param("service_tier") == "fast" ? 2 : 1) * (has(header("anthropic-beta"), "fast-mode-2026-02-01") ? 2.5 : 1)`,
|
cost, trace, err := billingexpr.RunExprWithRequest(
|
||||||
billingexpr.TokenParams{},
|
exprStr,
|
||||||
|
billingexpr.TokenParams{P: 10},
|
||||||
billingexpr.RequestInput{
|
billingexpr.RequestInput{
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
"Anthropic-Beta": "fast-mode-2026-02-01",
|
"Anthropic-Beta": "fast-mode-2026-02-01",
|
||||||
@@ -239,12 +242,62 @@ func TestRequestProbeMultipleRulesMultiply(t *testing.T) {
|
|||||||
Body: []byte(`{"service_tier":"fast"}`),
|
Body: []byte(`{"service_tier":"fast"}`),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
require.NoError(t, err)
|
||||||
}
|
assert.InDelta(t, 100, cost, 1e-6)
|
||||||
if math.Abs(cost-5) > 1e-6 {
|
assert.Equal(t, "base", trace.MatchedTier)
|
||||||
t.Errorf("cost = %f, want 5", cost)
|
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) {
|
func TestCeilFloor(t *testing.T) {
|
||||||
|
|||||||
+111
-6
@@ -16,6 +16,11 @@ const maxCacheSize = 256
|
|||||||
// DefaultExprVersion is used when an expression string has no version prefix.
|
// DefaultExprVersion is used when an expression string has no version prefix.
|
||||||
const DefaultExprVersion = 1
|
const DefaultExprVersion = 1
|
||||||
|
|
||||||
|
const (
|
||||||
|
requestRuleTraceFunction = "_trace"
|
||||||
|
requestRuleTraceIntFunction = "_trace_int"
|
||||||
|
)
|
||||||
|
|
||||||
// ParseExprVersion extracts the version tag and body from an expression string.
|
// ParseExprVersion extracts the version tag and body from an expression string.
|
||||||
// Format: "v1:tier(...)" → version=1, body="tier(...)".
|
// Format: "v1:tier(...)" → version=1, body="tier(...)".
|
||||||
// No prefix defaults to DefaultExprVersion.
|
// No prefix defaults to DefaultExprVersion.
|
||||||
@@ -26,9 +31,87 @@ func ParseExprVersion(exprStr string) (version int, body string) {
|
|||||||
return DefaultExprVersion, exprStr
|
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 {
|
type cachedEntry struct {
|
||||||
prog *vm.Program
|
prog *vm.Program
|
||||||
usedVars map[string]bool
|
usedVars map[string]bool
|
||||||
|
requestRules []RequestRuleTrace
|
||||||
version int
|
version int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +133,8 @@ var compileEnvPrototypeV1 = map[string]interface{}{
|
|||||||
"ai": float64(0),
|
"ai": float64(0),
|
||||||
"ao": float64(0),
|
"ao": float64(0),
|
||||||
"tier": func(string, float64) float64 { return 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 "" },
|
"header": func(string) string { return "" },
|
||||||
"param": func(string) interface{} { return nil },
|
"param": func(string) interface{} { return nil },
|
||||||
"has": func(interface{}, string) bool { return false },
|
"has": func(interface{}, string) bool { return false },
|
||||||
@@ -85,29 +170,45 @@ func CompileFromCacheByHash(exprStr, hash string) (*vm.Program, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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()
|
cacheMu.RLock()
|
||||||
if entry, ok := cache[hash]; ok {
|
if entry, ok := cache[hash]; ok {
|
||||||
cacheMu.RUnlock()
|
cacheMu.RUnlock()
|
||||||
return entry.prog, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
cacheMu.RUnlock()
|
cacheMu.RUnlock()
|
||||||
|
|
||||||
version, body := ParseExprVersion(exprStr)
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("expr compile error: %w", err)
|
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()
|
cacheMu.Lock()
|
||||||
if len(cache) >= maxCacheSize {
|
if len(cache) >= maxCacheSize {
|
||||||
cache = make(map[string]*cachedEntry, 64)
|
cache = make(map[string]*cachedEntry, 64)
|
||||||
}
|
}
|
||||||
cache[hash] = &cachedEntry{prog: prog, usedVars: vars, version: version}
|
cache[hash] = entry
|
||||||
cacheMu.Unlock()
|
cacheMu.Unlock()
|
||||||
|
|
||||||
return prog, nil
|
return entry, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExprVersion returns the version of a cached expression. Returns DefaultExprVersion
|
// 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()
|
node := prog.Node()
|
||||||
ast.Find(node, func(n ast.Node) bool {
|
ast.Find(node, func(n ast.Node) bool {
|
||||||
if id, ok := n.(*ast.IdentifierNode); ok {
|
if id, ok := n.(*ast.IdentifierNode); ok {
|
||||||
|
switch id.Value {
|
||||||
|
case requestRuleTraceFunction, requestRuleTraceIntFunction:
|
||||||
|
return false
|
||||||
|
}
|
||||||
vars[id.Value] = true
|
vars[id.Value] = true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
+27
-3
@@ -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
|
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`
|
**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
@@ -26,11 +26,11 @@ func RunExpr(exprStr string, params TokenParams) (float64, TraceResult, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func RunExprWithRequest(exprStr string, params TokenParams, request RequestInput) (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 {
|
if err != nil {
|
||||||
return 0, TraceResult{}, err
|
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
|
// 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) {
|
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 {
|
if err != nil {
|
||||||
return 0, TraceResult{}, err
|
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) {
|
func runProgram(prog *vm.Program, requestRules []RequestRuleTrace, params TokenParams, request RequestInput) (float64, TraceResult, error) {
|
||||||
trace := TraceResult{}
|
trace := TraceResult{
|
||||||
|
RequestRules: append([]RequestRuleTrace(nil), requestRules...),
|
||||||
|
}
|
||||||
headers := normalizeHeaders(request.Headers)
|
headers := normalizeHeaders(request.Headers)
|
||||||
|
|
||||||
env := map[string]interface{}{
|
env := map[string]interface{}{
|
||||||
@@ -68,6 +70,24 @@ func runProgram(prog *vm.Program, params TokenParams, request RequestInput) (flo
|
|||||||
trace.Cost = value
|
trace.Cost = value
|
||||||
return 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 {
|
"header": func(key string) string {
|
||||||
return headers[strings.ToLower(strings.TrimSpace(key))]
|
return headers[strings.ToLower(strings.TrimSpace(key))]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ func ComputeTieredQuotaWithRequest(snap *BillingSnapshot, params TokenParams, re
|
|||||||
ActualQuotaBeforeGroup: quotaBeforeGroup,
|
ActualQuotaBeforeGroup: quotaBeforeGroup,
|
||||||
ActualQuotaAfterGroup: afterGroup,
|
ActualQuotaAfterGroup: afterGroup,
|
||||||
MatchedTier: trace.MatchedTier,
|
MatchedTier: trace.MatchedTier,
|
||||||
|
RequestRules: trace.RequestRules,
|
||||||
CrossedTier: crossed,
|
CrossedTier: crossed,
|
||||||
Clamp: clamp,
|
Clamp: clamp,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -28,11 +28,17 @@ type TokenParams struct {
|
|||||||
AO float64 // audio output tokens
|
AO float64 // audio output tokens
|
||||||
}
|
}
|
||||||
|
|
||||||
// TraceResult holds side-channel info captured by the tier() function
|
// RequestRuleTrace describes one request-dependent multiplier detected at compile time.
|
||||||
// during Expr execution. This replaces the old Breakdown mechanism —
|
type RequestRuleTrace struct {
|
||||||
// the Expr itself is the single source of truth for billing logic.
|
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 {
|
type TraceResult struct {
|
||||||
MatchedTier string `json:"matched_tier"`
|
MatchedTier string `json:"matched_tier"`
|
||||||
|
RequestRules []RequestRuleTrace `json:"request_rules,omitempty"`
|
||||||
Cost float64 `json:"cost"`
|
Cost float64 `json:"cost"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,6 +66,7 @@ type TieredResult struct {
|
|||||||
ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"`
|
ActualQuotaBeforeGroup float64 `json:"actual_quota_before_group"`
|
||||||
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
|
ActualQuotaAfterGroup int `json:"actual_quota_after_group"`
|
||||||
MatchedTier string `json:"matched_tier"`
|
MatchedTier string `json:"matched_tier"`
|
||||||
|
RequestRules []RequestRuleTrace `json:"request_rules,omitempty"`
|
||||||
CrossedTier bool `json:"crossed_tier"`
|
CrossedTier bool `json:"crossed_tier"`
|
||||||
// Clamp records an int32 saturation event during quota conversion so the
|
// 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
|
// caller can surface it on the consume log for admin auditing. Nil when no
|
||||||
|
|||||||
@@ -316,5 +316,8 @@ func InjectTieredBillingInfo(other map[string]interface{}, relayInfo *relaycommo
|
|||||||
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
|
other["expr_b64"] = base64.StdEncoding.EncodeToString([]byte(snap.ExprString))
|
||||||
if result != nil {
|
if result != nil {
|
||||||
other["matched_tier"] = result.MatchedTier
|
other["matched_tier"] = result.MatchedTier
|
||||||
|
if len(result.RequestRules) > 0 {
|
||||||
|
other["request_rules"] = result.RequestRules
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,13 @@ import {
|
|||||||
SOURCE_TIME,
|
SOURCE_TIME,
|
||||||
normalizeTierLabel,
|
normalizeTierLabel,
|
||||||
parseTiersFromExpr,
|
parseTiersFromExpr,
|
||||||
|
requestRuleGroupsFromTrace,
|
||||||
splitBillingExprAndRequestRules,
|
splitBillingExprAndRequestRules,
|
||||||
tryParseRequestRuleExpr,
|
tryParseRequestRuleExpr,
|
||||||
type ParsedTier,
|
type ParsedTier,
|
||||||
type RequestCondition,
|
type RequestCondition,
|
||||||
type RequestRuleGroup,
|
type RequestRuleGroup,
|
||||||
|
type RequestRuleTrace,
|
||||||
type TierCondition,
|
type TierCondition,
|
||||||
} from '../lib/billing-expr'
|
} from '../lib/billing-expr'
|
||||||
|
|
||||||
@@ -52,6 +54,8 @@ type DynamicPricingBreakdownProps = {
|
|||||||
* the usage-log details dialog to show which tier the engine selected.
|
* the usage-log details dialog to show which tier the engine selected.
|
||||||
*/
|
*/
|
||||||
matchedTierLabel?: string | null
|
matchedTierLabel?: string | null
|
||||||
|
/** Request-rule traces emitted by the settlement run. */
|
||||||
|
requestRules?: RequestRuleTrace[] | null
|
||||||
/**
|
/**
|
||||||
* Hide cache-pricing columns regardless of the per-tier values. The log
|
* Hide cache-pricing columns regardless of the per-tier values. The log
|
||||||
* details dialog passes this when the actual request did not consume any
|
* details dialog passes this when the actual request did not consume any
|
||||||
@@ -148,14 +152,25 @@ function describeGroup(
|
|||||||
group: RequestRuleGroup,
|
group: RequestRuleGroup,
|
||||||
t: (key: string) => string
|
t: (key: string) => string
|
||||||
): string {
|
): string {
|
||||||
return (group.conditions || [])
|
const description = (group.conditions || [])
|
||||||
.map((c) => describeCondition(c, t))
|
.map((condition) => describeCondition(condition, t))
|
||||||
.join(' && ')
|
.join(' && ')
|
||||||
|
return description || group.conditionText || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextOccurrenceKey(
|
||||||
|
baseKey: string,
|
||||||
|
occurrences: Map<string, number>
|
||||||
|
): string {
|
||||||
|
const occurrence = occurrences.get(baseKey) || 0
|
||||||
|
occurrences.set(baseKey, occurrence + 1)
|
||||||
|
return `${baseKey}:${occurrence}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicPricingBreakdown({
|
export function DynamicPricingBreakdown({
|
||||||
billingExpr,
|
billingExpr,
|
||||||
matchedTierLabel,
|
matchedTierLabel,
|
||||||
|
requestRules,
|
||||||
hideCacheColumns = false,
|
hideCacheColumns = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: DynamicPricingBreakdownProps) {
|
}: DynamicPricingBreakdownProps) {
|
||||||
@@ -179,12 +194,15 @@ export function DynamicPricingBreakdown({
|
|||||||
const { tiers, ruleGroups } = useMemo(() => {
|
const { tiers, ruleGroups } = useMemo(() => {
|
||||||
const split = splitBillingExprAndRequestRules(expr)
|
const split = splitBillingExprAndRequestRules(expr)
|
||||||
const parsedTiers = parseTiersFromExpr(split.billingExpr)
|
const parsedTiers = parseTiersFromExpr(split.billingExpr)
|
||||||
const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '')
|
const parsedRules =
|
||||||
|
requestRules != null
|
||||||
|
? requestRuleGroupsFromTrace(requestRules)
|
||||||
|
: tryParseRequestRuleExpr(split.requestRuleExpr || '')
|
||||||
return {
|
return {
|
||||||
tiers: parsedTiers,
|
tiers: parsedTiers,
|
||||||
ruleGroups: parsedRules || [],
|
ruleGroups: parsedRules || [],
|
||||||
}
|
}
|
||||||
}, [expr])
|
}, [expr, requestRules])
|
||||||
|
|
||||||
const hasTiers = tiers.length > 0
|
const hasTiers = tiers.length > 0
|
||||||
const hasRules = ruleGroups.length > 0
|
const hasRules = ruleGroups.length > 0
|
||||||
@@ -229,6 +247,8 @@ export function DynamicPricingBreakdown({
|
|||||||
(tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0
|
(tier) => Number(tier[v.field as string as keyof ParsedTier] || 0) > 0
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
const mobileTierKeyOccurrences = new Map<string, number>()
|
||||||
|
const requestRuleKeyOccurrences = new Map<string, number>()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className={cn('min-w-0', !compact && 'py-3 sm:py-4')}>
|
<section className={cn('min-w-0', !compact && 'py-3 sm:py-4')}>
|
||||||
@@ -260,15 +280,19 @@ export function DynamicPricingBreakdown({
|
|||||||
{t('Tiered price table')}
|
{t('Tiered price table')}
|
||||||
</div>
|
</div>
|
||||||
<div className='space-y-1.5 sm:hidden'>
|
<div className='space-y-1.5 sm:hidden'>
|
||||||
{tiers.map((tier, i) => {
|
{tiers.map((tier) => {
|
||||||
const condSummary = formatConditionSummary(tier.conditions, t)
|
const condSummary = formatConditionSummary(tier.conditions, t)
|
||||||
const isMatched =
|
const isMatched =
|
||||||
matchedTierLabel != null &&
|
matchedTierLabel != null &&
|
||||||
matchedTierLabel !== '' &&
|
matchedTierLabel !== '' &&
|
||||||
tier.label === matchedTierLabel
|
tier.label === matchedTierLabel
|
||||||
|
const rowKey = nextOccurrenceKey(
|
||||||
|
JSON.stringify(tier),
|
||||||
|
mobileTierKeyOccurrences
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`tier-mobile-${i}`}
|
key={`tier-mobile-${rowKey}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md border p-2',
|
'rounded-md border p-2',
|
||||||
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
|
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
|
||||||
@@ -425,10 +449,19 @@ export function DynamicPricingBreakdown({
|
|||||||
{t('Conditional multipliers')}
|
{t('Conditional multipliers')}
|
||||||
</div>
|
</div>
|
||||||
<ul className='space-y-1.5'>
|
<ul className='space-y-1.5'>
|
||||||
{ruleGroups.map((group, gi) => (
|
{ruleGroups.map((group) => {
|
||||||
|
const isMatched = group.matched === true
|
||||||
|
const rowKey = nextOccurrenceKey(
|
||||||
|
`${group.conditionText || JSON.stringify(group.conditions)}:${group.multiplier}`,
|
||||||
|
requestRuleKeyOccurrences
|
||||||
|
)
|
||||||
|
return (
|
||||||
<li
|
<li
|
||||||
key={`group-${gi}`}
|
key={`group-${rowKey}`}
|
||||||
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
className={cn(
|
||||||
|
'bg-muted/50 flex items-center justify-between gap-3 rounded-md border border-transparent px-3 py-2',
|
||||||
|
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -440,12 +473,17 @@ export function DynamicPricingBreakdown({
|
|||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
variant='secondary'
|
variant='secondary'
|
||||||
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
|
className={cn(
|
||||||
|
'shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300',
|
||||||
|
isMatched &&
|
||||||
|
'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{group.multiplier}x
|
{group.multiplier}x{isMatched && ` · ${t('Matched')}`}
|
||||||
</Badge>
|
</Badge>
|
||||||
</li>
|
</li>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -226,6 +226,14 @@ export type RequestCondition = TimeCondition | ParamHeaderCondition
|
|||||||
export type RequestRuleGroup = {
|
export type RequestRuleGroup = {
|
||||||
conditions: RequestCondition[]
|
conditions: RequestCondition[]
|
||||||
multiplier: string
|
multiplier: string
|
||||||
|
conditionText?: string
|
||||||
|
matched?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestRuleTrace = {
|
||||||
|
cond: string
|
||||||
|
multiplier: number
|
||||||
|
matched: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TierCondition = {
|
export type TierCondition = {
|
||||||
@@ -307,9 +315,9 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
|
|||||||
export function normalizeTierLabel(label: string | undefined): string {
|
export function normalizeTierLabel(label: string | undefined): string {
|
||||||
if (!label) return ''
|
if (!label) return ''
|
||||||
return label
|
return label
|
||||||
.replace(/<[==]?|≤|<[==]?/g, '<')
|
.replaceAll(/<[==]?|≤|<[==]?/g, '<')
|
||||||
.replace(/>[==]?|≥|>[==]?/g, '>')
|
.replaceAll(/>[==]?|≥|>[==]?/g, '>')
|
||||||
.replace(/\s+/g, '')
|
.replaceAll(/\s+/g, '')
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -426,24 +434,26 @@ function tryParseRequestCondition(expr: string): RequestCondition | null {
|
|||||||
if (m) return { source: 'param', path: m[1], mode: MATCH_EXISTS, value: '' }
|
if (m) return { source: 'param', path: m[1], mode: MATCH_EXISTS, value: '' }
|
||||||
|
|
||||||
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/)
|
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/)
|
||||||
if (m)
|
if (m) {
|
||||||
return {
|
return {
|
||||||
source: 'header',
|
source: 'header',
|
||||||
path: m[1],
|
path: m[1],
|
||||||
mode: MATCH_CONTAINS,
|
mode: MATCH_CONTAINS,
|
||||||
value: JSON.parse(m[2]) as string,
|
value: JSON.parse(m[2]) as string,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m = expr.match(
|
m = expr.match(
|
||||||
/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/
|
/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/
|
||||||
)
|
)
|
||||||
if (m && m[1] === m[2])
|
if (m && m[1] === m[2]) {
|
||||||
return {
|
return {
|
||||||
source: 'param',
|
source: 'param',
|
||||||
path: m[1],
|
path: m[1],
|
||||||
mode: MATCH_CONTAINS,
|
mode: MATCH_CONTAINS,
|
||||||
value: JSON.parse(m[3]) as string,
|
value: JSON.parse(m[3]) as string,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m = expr.match(
|
m = expr.match(
|
||||||
/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/
|
/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/
|
||||||
@@ -473,22 +483,40 @@ function tryParseRequestCondition(expr: string): RequestCondition | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tryParseRequestConditions(
|
||||||
|
conditionStr: string
|
||||||
|
): RequestCondition[] | null {
|
||||||
|
const andParts = splitTopLevelAnd(conditionStr)
|
||||||
|
const conditions: RequestCondition[] = []
|
||||||
|
for (const part of andParts) {
|
||||||
|
const condition = tryParseRequestCondition(part.trim())
|
||||||
|
if (!condition) return null
|
||||||
|
conditions.push(condition)
|
||||||
|
}
|
||||||
|
return conditions.length > 0 ? conditions : null
|
||||||
|
}
|
||||||
|
|
||||||
function tryParseRuleGroupFactor(part: string): RequestRuleGroup | null {
|
function tryParseRuleGroupFactor(part: string): RequestRuleGroup | null {
|
||||||
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s)
|
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s)
|
||||||
if (!m) return null
|
if (!m) return null
|
||||||
|
|
||||||
const conditionStr = m[1]
|
const conditions = tryParseRequestConditions(m[1])
|
||||||
const multiplier = m[2]
|
if (!conditions) return null
|
||||||
|
return { conditions, multiplier: m[2] }
|
||||||
|
}
|
||||||
|
|
||||||
const andParts = splitTopLevelAnd(conditionStr)
|
export function requestRuleGroupsFromTrace(
|
||||||
const conditions: RequestCondition[] = []
|
requestRules: RequestRuleTrace[]
|
||||||
for (const ap of andParts) {
|
): RequestRuleGroup[] {
|
||||||
const cond = tryParseRequestCondition(ap.trim())
|
return requestRules.map((rule) => {
|
||||||
if (!cond) return null
|
const conditionText = rule.cond.trim()
|
||||||
conditions.push(cond)
|
return {
|
||||||
|
conditions: tryParseRequestConditions(conditionText) || [],
|
||||||
|
multiplier: String(rule.multiplier),
|
||||||
|
conditionText,
|
||||||
|
matched: rule.matched,
|
||||||
}
|
}
|
||||||
if (conditions.length === 0) return null
|
})
|
||||||
return { conditions, multiplier }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function tryParseRequestRuleExpr(
|
export function tryParseRequestRuleExpr(
|
||||||
@@ -642,12 +670,12 @@ function isTimeFunc(value: unknown): value is TimeFunc {
|
|||||||
export function normalizeCondition(
|
export function normalizeCondition(
|
||||||
cond: Partial<RequestCondition> | null | undefined
|
cond: Partial<RequestCondition> | null | undefined
|
||||||
): RequestCondition {
|
): RequestCondition {
|
||||||
const source =
|
let source: RequestCondition['source'] = 'param'
|
||||||
cond?.source === 'time'
|
if (cond?.source === 'time') {
|
||||||
? 'time'
|
source = 'time'
|
||||||
: cond?.source === 'header'
|
} else if (cond?.source === 'header') {
|
||||||
? 'header'
|
source = 'header'
|
||||||
: 'param'
|
}
|
||||||
|
|
||||||
if (source === 'time') {
|
if (source === 'time') {
|
||||||
const timeCond = cond as Partial<TimeCondition> | null | undefined
|
const timeCond = cond as Partial<TimeCondition> | null | undefined
|
||||||
|
|||||||
@@ -1078,6 +1078,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
|
|||||||
compact
|
compact
|
||||||
billingExpr={decodeBillingExprB64(other.expr_b64)}
|
billingExpr={decodeBillingExprB64(other.expr_b64)}
|
||||||
matchedTierLabel={other.matched_tier}
|
matchedTierLabel={other.matched_tier}
|
||||||
|
requestRules={other.request_rules}
|
||||||
hideCacheColumns={!hasAnyCacheTokens(other)}
|
hideCacheColumns={!hasAnyCacheTokens(other)}
|
||||||
/>
|
/>
|
||||||
</DetailSection>
|
</DetailSection>
|
||||||
|
|||||||
@@ -19,8 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
|
|||||||
/**
|
/**
|
||||||
* Type definitions for usage logs
|
* Type definitions for usage logs
|
||||||
*/
|
*/
|
||||||
import type { UsageLog } from './data/schema'
|
import type { RequestRuleTrace } from '@/features/pricing/lib/billing-expr'
|
||||||
|
|
||||||
|
import type { UsageLog } from './data/schema'
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Log Category Types
|
// Log Category Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -189,10 +190,12 @@ export interface LogOtherData {
|
|||||||
frt?: number
|
frt?: number
|
||||||
// Tiered (expression-based) billing fields, set by backend when
|
// Tiered (expression-based) billing fields, set by backend when
|
||||||
// billing_mode === 'tiered_expr'. expr_b64 is the base64-encoded billing
|
// billing_mode === 'tiered_expr'. expr_b64 is the base64-encoded billing
|
||||||
// expression and matched_tier is the label of the tier that fired.
|
// expression; the matched tier and request-rule traces come from the actual
|
||||||
|
// settlement run.
|
||||||
billing_mode?: string
|
billing_mode?: string
|
||||||
expr_b64?: string
|
expr_b64?: string
|
||||||
matched_tier?: string
|
matched_tier?: string
|
||||||
|
request_rules?: RequestRuleTrace[]
|
||||||
reasoning_effort?: string
|
reasoning_effort?: string
|
||||||
image?: boolean
|
image?: boolean
|
||||||
image_ratio?: number
|
image_ratio?: number
|
||||||
|
|||||||
Reference in New Issue
Block a user