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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user