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
@@ -36,11 +36,13 @@ import {
SOURCE_TIME,
normalizeTierLabel,
parseTiersFromExpr,
requestRuleGroupsFromTrace,
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
type ParsedTier,
type RequestCondition,
type RequestRuleGroup,
type RequestRuleTrace,
type TierCondition,
} from '../lib/billing-expr'
@@ -52,6 +54,8 @@ type DynamicPricingBreakdownProps = {
* the usage-log details dialog to show which tier the engine selected.
*/
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
* details dialog passes this when the actual request did not consume any
@@ -148,14 +152,25 @@ function describeGroup(
group: RequestRuleGroup,
t: (key: string) => string
): string {
return (group.conditions || [])
.map((c) => describeCondition(c, t))
const description = (group.conditions || [])
.map((condition) => describeCondition(condition, t))
.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({
billingExpr,
matchedTierLabel,
requestRules,
hideCacheColumns = false,
compact = false,
}: DynamicPricingBreakdownProps) {
@@ -179,12 +194,15 @@ export function DynamicPricingBreakdown({
const { tiers, ruleGroups } = useMemo(() => {
const split = splitBillingExprAndRequestRules(expr)
const parsedTiers = parseTiersFromExpr(split.billingExpr)
const parsedRules = tryParseRequestRuleExpr(split.requestRuleExpr || '')
const parsedRules =
requestRules != null
? requestRuleGroupsFromTrace(requestRules)
: tryParseRequestRuleExpr(split.requestRuleExpr || '')
return {
tiers: parsedTiers,
ruleGroups: parsedRules || [],
}
}, [expr])
}, [expr, requestRules])
const hasTiers = tiers.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
)
})
const mobileTierKeyOccurrences = new Map<string, number>()
const requestRuleKeyOccurrences = new Map<string, number>()
return (
<section className={cn('min-w-0', !compact && 'py-3 sm:py-4')}>
@@ -260,15 +280,19 @@ export function DynamicPricingBreakdown({
{t('Tiered price table')}
</div>
<div className='space-y-1.5 sm:hidden'>
{tiers.map((tier, i) => {
{tiers.map((tier) => {
const condSummary = formatConditionSummary(tier.conditions, t)
const isMatched =
matchedTierLabel != null &&
matchedTierLabel !== '' &&
tier.label === matchedTierLabel
const rowKey = nextOccurrenceKey(
JSON.stringify(tier),
mobileTierKeyOccurrences
)
return (
<div
key={`tier-mobile-${i}`}
key={`tier-mobile-${rowKey}`}
className={cn(
'rounded-md border p-2',
isMatched && 'border-emerald-500/40 bg-emerald-500/10'
@@ -425,27 +449,41 @@ export function DynamicPricingBreakdown({
{t('Conditional multipliers')}
</div>
<ul className='space-y-1.5'>
{ruleGroups.map((group, gi) => (
<li
key={`group-${gi}`}
className='bg-muted/50 flex items-center justify-between gap-3 rounded-md px-3 py-2'
>
<span
{ruleGroups.map((group) => {
const isMatched = group.matched === true
const rowKey = nextOccurrenceKey(
`${group.conditionText || JSON.stringify(group.conditions)}:${group.multiplier}`,
requestRuleKeyOccurrences
)
return (
<li
key={`group-${rowKey}`}
className={cn(
'text-foreground break-all',
compact ? 'text-xs' : 'text-sm'
'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'
)}
>
{describeGroup(group, t)}
</span>
<Badge
variant='secondary'
className='shrink-0 bg-orange-100 text-orange-700 dark:bg-orange-500/20 dark:text-orange-300'
>
{group.multiplier}x
</Badge>
</li>
))}
<span
className={cn(
'text-foreground break-all',
compact ? 'text-xs' : 'text-sm'
)}
>
{describeGroup(group, t)}
</span>
<Badge
variant='secondary'
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{isMatched && ` · ${t('Matched')}`}
</Badge>
</li>
)
})}
</ul>
</div>
)}
+50 -22
View File
@@ -226,6 +226,14 @@ export type RequestCondition = TimeCondition | ParamHeaderCondition
export type RequestRuleGroup = {
conditions: RequestCondition[]
multiplier: string
conditionText?: string
matched?: boolean
}
export type RequestRuleTrace = {
cond: string
multiplier: number
matched: boolean
}
export type TierCondition = {
@@ -307,9 +315,9 @@ export function parseTiersFromExpr(exprStr: string): ParsedTier[] {
export function normalizeTierLabel(label: string | undefined): string {
if (!label) return ''
return label
.replace(/<[=]?|≤|[=]?/g, '<')
.replace(/>[=]?|≥|[=]?/g, '>')
.replace(/\s+/g, '')
.replaceAll(/<[=]?|≤|[=]?/g, '<')
.replaceAll(/>[=]?|≥|[=]?/g, '>')
.replaceAll(/\s+/g, '')
.toLowerCase()
}
@@ -426,24 +434,26 @@ function tryParseRequestCondition(expr: string): RequestCondition | null {
if (m) return { source: 'param', path: m[1], mode: MATCH_EXISTS, value: '' }
m = expr.match(/^has\(header\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/)
if (m)
if (m) {
return {
source: 'header',
path: m[1],
mode: MATCH_CONTAINS,
value: JSON.parse(m[2]) as string,
}
}
m = expr.match(
/^param\("([^"]+)"\) != nil && has\(param\("([^"]+)"\), ((?:"(?:[^"\\]|\\.)*"))\)$/
)
if (m && m[1] === m[2])
if (m && m[1] === m[2]) {
return {
source: 'param',
path: m[1],
mode: MATCH_CONTAINS,
value: JSON.parse(m[3]) as string,
}
}
m = expr.match(
/^param\("([^"]+)"\) != nil && param\("([^"]+)"\) (>|>=|<|<=) ([\d.eE+-]+)$/
@@ -473,22 +483,40 @@ function tryParseRequestCondition(expr: string): RequestCondition | 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 {
const m = part.match(/^\((.+) \? ([\d.eE+-]+) : 1\)$/s)
if (!m) return null
const conditionStr = m[1]
const multiplier = m[2]
const conditions = tryParseRequestConditions(m[1])
if (!conditions) return null
return { conditions, multiplier: m[2] }
}
const andParts = splitTopLevelAnd(conditionStr)
const conditions: RequestCondition[] = []
for (const ap of andParts) {
const cond = tryParseRequestCondition(ap.trim())
if (!cond) return null
conditions.push(cond)
}
if (conditions.length === 0) return null
return { conditions, multiplier }
export function requestRuleGroupsFromTrace(
requestRules: RequestRuleTrace[]
): RequestRuleGroup[] {
return requestRules.map((rule) => {
const conditionText = rule.cond.trim()
return {
conditions: tryParseRequestConditions(conditionText) || [],
multiplier: String(rule.multiplier),
conditionText,
matched: rule.matched,
}
})
}
export function tryParseRequestRuleExpr(
@@ -642,12 +670,12 @@ function isTimeFunc(value: unknown): value is TimeFunc {
export function normalizeCondition(
cond: Partial<RequestCondition> | null | undefined
): RequestCondition {
const source =
cond?.source === 'time'
? 'time'
: cond?.source === 'header'
? 'header'
: 'param'
let source: RequestCondition['source'] = 'param'
if (cond?.source === 'time') {
source = 'time'
} else if (cond?.source === 'header') {
source = 'header'
}
if (source === 'time') {
const timeCond = cond as Partial<TimeCondition> | null | undefined
@@ -1078,6 +1078,7 @@ export function DetailsDialog(props: DetailsDialogProps) {
compact
billingExpr={decodeBillingExprB64(other.expr_b64)}
matchedTierLabel={other.matched_tier}
requestRules={other.request_rules}
hideCacheColumns={!hasAnyCacheTokens(other)}
/>
</DetailSection>
+5 -2
View File
@@ -19,8 +19,9 @@ For commercial licensing, please contact support@quantumnous.com
/**
* 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
// ============================================================================
@@ -189,10 +190,12 @@ export interface LogOtherData {
frt?: number
// Tiered (expression-based) billing fields, set by backend when
// 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
expr_b64?: string
matched_tier?: string
request_rules?: RequestRuleTrace[]
reasoning_effort?: string
image?: boolean
image_ratio?: number