Files
chaos-api/web/default/src/features/system-settings/models/tiered-pricing-editor.tsx
T
CaIon 0f9f094a48 feat(default): reorganize system settings pricing UI
Refine the default system settings structure and model pricing editor so pricing configuration is easier to scan and edit.
2026-05-06 16:29:45 +08:00

1802 lines
54 KiB
TypeScript
Vendored
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
type FocusEvent,
type InputHTMLAttributes,
type MouseEvent as ReactMouseEvent,
} from 'react'
import { ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
BILLING_EXTRA_VARS,
COMMON_TIMEZONES,
MATCH_CONTAINS,
MATCH_EQ,
MATCH_EXISTS,
MATCH_GT,
MATCH_GTE,
MATCH_LT,
MATCH_LTE,
MATCH_RANGE,
SOURCE_HEADER,
SOURCE_PARAM,
SOURCE_TIME,
TIME_FUNCS,
buildRequestRuleExpr,
combineBillingExpr,
createEmptyCondition,
createEmptyRuleGroup,
createEmptyTimeCondition,
getRequestRuleMatchOptions,
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
type ParamHeaderCondition,
type RequestCondition,
type RequestRuleGroup,
type TimeCondition,
type TimeFunc,
} from '@/features/pricing/lib/billing-expr'
import {
CACHE_MODE_GENERIC,
CACHE_MODE_TIMED,
type CacheMode,
type ExtraTokenValues,
type TierConditionInput,
type VisualConfig,
type VisualTier,
createDefaultVisualConfig,
evalExprLocally,
exprUsesExtraVars,
generateExprFromVisualConfig,
getTierCacheMode,
normalizeVisualConfig,
normalizeVisualTier,
tryParseVisualConfig,
} from '@/features/pricing/lib/tier-expr'
const PRICE_SUFFIX = '$/1M tokens'
const CACHE_PRICE_VARS = BILLING_EXTRA_VARS.filter(
(variable) => variable.group === 'cache'
)
const MEDIA_PRICE_VARS = BILLING_EXTRA_VARS.filter(
(variable) => variable.group === 'media'
)
const CONDITION_INPUT_OPTIONS: {
value: TierConditionInput['var']
labelKey: string
}[] = [
{ value: 'len', labelKey: 'Full input length' },
{ value: 'p', labelKey: 'Billable input tokens' },
{ value: 'c', labelKey: 'Billable output tokens' },
]
const OPS: TierConditionInput['op'][] = ['<', '<=', '>', '>=']
type Preset = {
key: string
label: string
expr: string
requestRules?: RequestRuleGroup[]
}
type PresetGroup = {
group: string
presets: Preset[]
}
const PRESET_GROUPS: PresetGroup[] = [
{
group: 'Fixed price',
presets: [
{ key: 'flat', label: 'Flat', expr: 'tier("base", p * 2 + c * 4)' },
{
key: 'claude-opus',
label: 'Claude Opus 4.6',
expr: 'tier("base", p * 5 + c * 25 + cr * 0.5 + cc * 6.25 + cc1h * 10)',
},
{
key: 'gpt-5.4',
label: 'GPT-5.4',
expr: 'len <= 272000 ? tier("standard", p * 2.5 + c * 15 + cr * 0.25) : tier("long_context", p * 5 + c * 22.5 + cr * 0.5)',
},
],
},
{
group: 'Tiered',
presets: [
{
key: 'claude-sonnet',
label: 'Claude Sonnet 4.5',
expr: 'len <= 200000 ? tier("standard", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6) : tier("long_context", p * 6 + c * 22.5 + cr * 0.6 + cc * 7.5 + cc1h * 12)',
},
{
key: 'qwen3-max',
label: 'Qwen3 Max',
expr: 'len <= 32000 ? tier("short", p * 1.2 + c * 6 + cr * 0.24 + cc * 1.5) : len <= 128000 ? tier("mid", p * 2.4 + c * 12 + cr * 0.48 + cc * 3) : tier("long", p * 3 + c * 15 + cr * 0.6 + cc * 3.75)',
},
{
key: 'glm-4.5-air',
label: 'GLM-4.5 Air',
expr: 'len < 32000 && c < 200 ? tier("short_output", p * 0.8 + c * 2 + cr * 0.16) : len < 32000 && c >= 200 ? tier("long_output", p * 0.8 + c * 6 + cr * 0.16) : tier("mid_context", p * 1.2 + c * 8 + cr * 0.24)',
},
{
key: 'doubao-seed-1.8',
label: 'Doubao Seed 1.8',
expr: 'len <= 32000 && c <= 200 ? tier("discount", p * 0.8 + c * 2 + cr * 0.16 + cc * 0.17) : len <= 32000 ? tier("short", p * 0.8 + c * 8 + cr * 0.16 + cc * 0.17) : len <= 128000 ? tier("mid", p * 1.2 + c * 16 + cr * 0.16 + cc * 0.17) : tier("long", p * 2.4 + c * 24 + cr * 0.16 + cc * 0.17)',
},
],
},
{
group: 'Multimodal',
presets: [
{
key: 'gpt-image-1-mini',
label: 'GPT Image 1 Mini',
expr: 'tier("base", p * 2 + c * 8 + img * 2.5)',
},
{
key: 'gemini-2.5-flash',
label: 'Gemini 2.5 Flash',
expr: 'tier("base", p * 0.3 + c * 2.5 + cr * 0.03 + ai * 1.0)',
},
{
key: 'gemini-3-pro-image',
label: 'Gemini 3 Pro Image',
expr: 'tier("base", p * 2 + c * 12 + img_o * 120)',
},
{
key: 'qwen3-omni-flash',
label: 'Qwen3 Omni Flash',
expr: 'tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)',
},
],
},
{
group: 'Request rule',
presets: [
{
key: 'claude-opus-fast',
label: 'Claude Opus 4.6 Fast',
expr: 'tier("base", p * 5 + c * 25 + cr * 0.5 + cc * 6.25 + cc1h * 10)',
requestRules: [
{
conditions: [
{
source: SOURCE_HEADER as 'header',
path: 'anthropic-beta',
mode: MATCH_CONTAINS,
value: 'fast-mode-2026-02-01',
},
],
multiplier: '6',
},
],
},
{
key: 'gpt-5.4-tiers',
label: 'GPT-5.4 Priority/Flex',
expr: 'len <= 272000 ? tier("standard", p * 2.5 + c * 15 + cr * 0.25) : tier("long_context", p * 5 + c * 22.5 + cr * 0.5)',
requestRules: [
{
conditions: [
{
source: SOURCE_PARAM as 'param',
path: 'service_tier',
mode: MATCH_EQ,
value: 'priority',
},
],
multiplier: '2',
},
{
conditions: [
{
source: SOURCE_PARAM as 'param',
path: 'service_tier',
mode: MATCH_EQ,
value: 'flex',
},
],
multiplier: '0.5',
},
],
},
],
},
{
group: 'Time-based',
presets: [
{
key: 'night-discount',
label: 'Night discount (50%)',
expr: 'tier("base", p * 3 + c * 15)',
requestRules: [
{
conditions: [
{
source: SOURCE_TIME as 'time',
timeFunc: 'hour',
timezone: 'Asia/Shanghai',
mode: MATCH_RANGE,
value: '',
rangeStart: '21',
rangeEnd: '6',
},
],
multiplier: '0.5',
},
],
},
{
key: 'weekend-discount',
label: 'Weekend discount (80%)',
expr: 'tier("base", p * 3 + c * 15)',
requestRules: [
{
conditions: [
{
source: SOURCE_TIME as 'time',
timeFunc: 'weekday',
timezone: 'Asia/Shanghai',
mode: MATCH_EQ,
value: '0',
rangeStart: '',
rangeEnd: '',
},
],
multiplier: '0.8',
},
{
conditions: [
{
source: SOURCE_TIME as 'time',
timeFunc: 'weekday',
timezone: 'Asia/Shanghai',
mode: MATCH_EQ,
value: '6',
rangeStart: '',
rangeEnd: '',
},
],
multiplier: '0.8',
},
],
},
],
},
]
function unitCostToPrice(uc: number | string): number {
return Number(uc) || 0
}
function priceToUnitCost(price: number | string): number {
return Number(price) || 0
}
function formatTokenHint(n: number | string | null | undefined): string {
if (n == null || n === '' || Number.isNaN(Number(n))) return ''
const v = Number(n)
if (v === 0) return '= 0'
if (v >= 1_000_000) return `= ${(v / 1_000_000).toLocaleString()}M tokens`
if (v >= 1_000) return `= ${(v / 1_000).toLocaleString()}K tokens`
return `= ${v.toLocaleString()} tokens`
}
function formatNumberDraft(value: number | string): string {
if (value === '') return ''
if (typeof value === 'number')
return Number.isFinite(value) ? String(value) : '0'
return value
}
function parseNumberDraft(value: string): number {
if (value.trim() === '') return 0
const next = Number(value)
return Number.isFinite(next) ? next : 0
}
function isZeroDraft(value: string): boolean {
return value.trim() !== '' && parseNumberDraft(value) === 0
}
type DraftNumberInputProps = Omit<
InputHTMLAttributes<HTMLInputElement>,
'type' | 'value' | 'onChange'
> & {
value: number | string
onValueChange: (next: number) => void
selectZeroOnFocus?: boolean
}
function DraftNumberInput({
value,
onValueChange,
selectZeroOnFocus = true,
onBlur,
onFocus,
onMouseUp,
...props
}: DraftNumberInputProps) {
const [draft, setDraft] = useState(() => formatNumberDraft(value))
const [focused, setFocused] = useState(false)
useEffect(() => {
if (!focused) {
setDraft(formatNumberDraft(value))
}
}, [focused, value])
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
const nextDraft = event.target.value
setDraft(nextDraft)
onValueChange(parseNumberDraft(nextDraft))
}
const handleFocus = (event: FocusEvent<HTMLInputElement>) => {
setFocused(true)
onFocus?.(event)
if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) {
event.currentTarget.select()
}
}
const handleMouseUp = (event: ReactMouseEvent<HTMLInputElement>) => {
onMouseUp?.(event)
if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) {
event.preventDefault()
event.currentTarget.select()
}
}
const handleBlur = (event: FocusEvent<HTMLInputElement>) => {
const normalized = parseNumberDraft(event.currentTarget.value)
setFocused(false)
setDraft(String(normalized))
onValueChange(normalized)
onBlur?.(event)
}
return (
<Input
{...props}
type='number'
value={draft}
onChange={handleChange}
onFocus={handleFocus}
onMouseUp={handleMouseUp}
onBlur={handleBlur}
/>
)
}
// ---------------------------------------------------------------------------
// Tier condition row
// ---------------------------------------------------------------------------
type ConditionRowProps = {
condition: TierConditionInput
onChange: (next: TierConditionInput) => void
onRemove: () => void
}
function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
const { t } = useTranslation()
const currentInputOption = CONDITION_INPUT_OPTIONS.find(
(option) => option.value === condition.var
)
return (
<div className='flex items-center gap-2'>
<Select
value={condition.var}
onValueChange={(value) =>
onChange({ ...condition, var: value as TierConditionInput['var'] })
}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue>
{currentInputOption
? t(currentInputOption.labelKey)
: condition.var}
</SelectValue>
</SelectTrigger>
<SelectContent>
{CONDITION_INPUT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.op}
onValueChange={(value) =>
onChange({ ...condition, op: value as TierConditionInput['op'] })
}
>
<SelectTrigger className='w-20' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPS.map((op) => (
<SelectItem key={op} value={op}>
{op}
</SelectItem>
))}
</SelectContent>
</Select>
<DraftNumberInput
min={0}
value={condition.value}
onValueChange={(value) => onChange({ ...condition, value })}
placeholder='tokens'
className='w-32'
/>
<span className='text-muted-foreground text-xs'>
{formatTokenHint(condition.value)}
</span>
<Button
variant='ghost'
size='icon'
onClick={onRemove}
aria-label='remove'
className='ml-auto'
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
)
}
// ---------------------------------------------------------------------------
// Price input field
// ---------------------------------------------------------------------------
type PriceFieldProps = {
label: string
hint?: string
value: number
onChange: (next: number) => void
}
function PriceField({ label, hint, value, onChange }: PriceFieldProps) {
return (
<div className='w-36 space-y-0.5'>
<Label className='text-muted-foreground text-xs'>{label}</Label>
<DraftNumberInput
min={0}
step={0.01}
value={Number.isFinite(value) ? value : 0}
onValueChange={onChange}
className='h-8 w-full'
/>
{hint && <p className='text-muted-foreground text-xs'>{hint}</p>}
</div>
)
}
// ---------------------------------------------------------------------------
// Single tier card (visual editor)
// ---------------------------------------------------------------------------
type VisualTierCardProps = {
tier: VisualTier
index: number
total: number
onChange: (next: VisualTier) => void
onRemove: () => void
onAddCondition: () => void
}
function VisualTierCard({
tier,
index,
total,
onChange,
onRemove,
onAddCondition,
}: VisualTierCardProps) {
const { t } = useTranslation()
const cacheMode = getTierCacheMode(tier)
const handleConditionChange = (
conditionIndex: number,
next: TierConditionInput
) => {
const conditions = [...tier.conditions]
conditions[conditionIndex] = next
onChange({ ...tier, conditions })
}
const handleConditionRemove = (conditionIndex: number) => {
onChange({
...tier,
conditions: tier.conditions.filter((_, i) => i !== conditionIndex),
})
}
const handlePriceChange = (field: keyof VisualTier, value: number) => {
onChange({ ...tier, [field]: value })
}
const handleCacheModeChange = (mode: CacheMode) => {
onChange({
...tier,
cache_mode: mode,
cache_create_1h_unit_cost:
mode === CACHE_MODE_TIMED ? (tier.cache_create_1h_unit_cost ?? 0) : 0,
})
}
const inputUnitPrice = unitCostToPrice(tier.input_unit_cost)
const outputUnitPrice = unitCostToPrice(tier.output_unit_cost)
const hasMediaPricing = MEDIA_PRICE_VARS.some((variable) => {
const fieldKey = variable.tierField as keyof VisualTier
return unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0) > 0
})
const [mediaOpen, setMediaOpen] = useState(hasMediaPricing)
useEffect(() => {
if (hasMediaPricing) setMediaOpen(true)
}, [hasMediaPricing])
const renderPriceVariable = (
variable: (typeof BILLING_EXTRA_VARS)[number]
) => {
const fieldKey = variable.tierField as keyof VisualTier
const value = unitCostToPrice((tier[fieldKey] as number | undefined) ?? 0)
return (
<PriceField
key={variable.key}
label={t(variable.label)}
value={value}
onChange={(next) => handlePriceChange(fieldKey, priceToUnitCost(next))}
/>
)
}
return (
<div className='space-y-3 rounded-lg border p-3'>
<div className='flex flex-wrap items-center justify-between gap-2'>
<div className='flex items-center gap-2'>
<Badge variant='outline'>
{t('Tier')} {index + 1} / {total}
</Badge>
{tier.conditions.length === 0 && (
<Badge variant='secondary'>{t('Fallback tier')}</Badge>
)}
<Input
value={tier.label}
onChange={(event) =>
onChange({ ...tier, label: event.target.value })
}
placeholder={t('Tier name')}
className='h-7 w-36'
/>
</div>
<Button
variant='ghost'
size='icon'
onClick={onRemove}
disabled={total <= 1}
aria-label={t('Remove tier')}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
{/* Conditions */}
<div className='space-y-1.5'>
<div className='flex h-7 items-center justify-between'>
<Label className='text-xs font-medium'>{t('Tier conditions')}</Label>
<Button
variant='ghost'
size='sm'
onClick={onAddCondition}
disabled={tier.conditions.length >= 2}
className='h-7 px-2 text-xs'
>
<Plus className='mr-1 h-3 w-3' />
{t('Add condition')}
</Button>
</div>
{tier.conditions.length === 0 ? (
<p className='text-muted-foreground text-xs'>
{t('Always matches (default tier).')}
</p>
) : (
tier.conditions.map((condition, conditionIndex) => (
<ConditionRow
key={conditionIndex}
condition={condition}
onChange={(next) => handleConditionChange(conditionIndex, next)}
onRemove={() => handleConditionRemove(conditionIndex)}
/>
))
)}
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between gap-3'>
<Label className='text-sm font-semibold'>{t('Token prices')}</Label>
<span className='bg-muted text-muted-foreground rounded-md px-2 py-1 text-xs'>
{PRICE_SUFFIX}
</span>
</div>
<div className='space-y-3'>
<div className='flex flex-wrap gap-x-4 gap-y-2'>
<PriceField
label={t('Input price')}
value={inputUnitPrice}
onChange={(value) =>
handlePriceChange('input_unit_cost', priceToUnitCost(value))
}
/>
<PriceField
label={t('Output price')}
value={outputUnitPrice}
onChange={(value) =>
handlePriceChange('output_unit_cost', priceToUnitCost(value))
}
/>
</div>
<div className='space-y-2'>
<div className='flex h-7 items-center'>
<Tabs
value={cacheMode}
onValueChange={(value) =>
value !== null && handleCacheModeChange(value as CacheMode)
}
>
<TabsList className='h-8'>
<TabsTrigger
value={CACHE_MODE_GENERIC}
className='px-2 text-xs'
>
{t('Generic cache')}
</TabsTrigger>
<TabsTrigger
value={CACHE_MODE_TIMED}
className='px-2 text-xs'
>
{t('Time-sliced cache (Claude)')}
</TabsTrigger>
</TabsList>
</Tabs>
</div>
<div className='flex flex-wrap gap-x-4 gap-y-2'>
{CACHE_PRICE_VARS.map((variable) => {
if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) {
return null
}
return renderPriceVariable(variable)
})}
</div>
</div>
</div>
</div>
{/* Media prices */}
<div className='space-y-1.5'>
<Button
type='button'
variant='ghost'
size='sm'
className='h-7 px-2 text-xs'
onClick={() => setMediaOpen((prev) => !prev)}
>
<ChevronDown
className={cn(
'mr-1 h-3 w-3 transition-transform',
mediaOpen && 'rotate-180'
)}
/>
{t('Media pricing')}
</Button>
{mediaOpen && (
<div className='flex flex-wrap gap-x-4 gap-y-2'>
{MEDIA_PRICE_VARS.map(renderPriceVariable)}
</div>
)}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Visual editor (list of tiers)
// ---------------------------------------------------------------------------
type VisualEditorProps = {
visualConfig: VisualConfig | null
onChange: (next: VisualConfig) => void
}
function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
const { t } = useTranslation()
const config = useMemo(
() => normalizeVisualConfig(visualConfig),
[visualConfig]
)
const handleTierChange = (index: number, next: VisualTier) => {
const tiers = [...config.tiers]
tiers[index] = normalizeVisualTier(next)
onChange({ ...config, tiers })
}
const handleAddTier = () => {
const tiers = [...config.tiers]
const lastIndex = tiers.length - 1
// When adding a new fallback, give the previous catch-all tier a default
// upper-bound condition so the expression compiles into a sane two-tier
// shape. Mirrors the classic editor's UX for adding tiers.
if (lastIndex >= 0 && tiers[lastIndex].conditions.length === 0) {
tiers[lastIndex] = normalizeVisualTier({
...tiers[lastIndex],
conditions: [{ var: 'len', op: '<', value: 200000 }],
})
}
tiers.push(
normalizeVisualTier({
label: `tier_${tiers.length + 1}`,
conditions: [],
input_unit_cost: 0,
output_unit_cost: 0,
})
)
onChange({ ...config, tiers })
}
const handleRemoveTier = (index: number) => {
const tiers = config.tiers.filter((_, i) => i !== index)
onChange({ ...config, tiers: tiers.length > 0 ? tiers : config.tiers })
}
const handleAddCondition = (index: number) => {
const tier = config.tiers[index]
if (tier.conditions.length >= 2) return
// Prefer `len` (input length) over `p`/`c` for tier conditions because
// `p` is subject to auto-exclusion when sub-categories like `cr` are
// priced separately, which can misroute long-input requests into shorter
// tiers when cache-hits reduce the effective `p`.
const usedVars = new Set(tier.conditions.map((c) => c.var))
const nextVar: TierConditionInput['var'] = usedVars.has('len') ? 'c' : 'len'
onChange({
...config,
tiers: config.tiers.map((current, i) =>
i === index
? {
...current,
conditions: [
...tier.conditions,
{ var: nextVar, op: '<', value: 200000 },
],
}
: current
),
})
}
return (
<div className='space-y-2'>
<p className='text-muted-foreground text-xs'>
{t(
'Each tier supports up to 2 conditions. The last tier without conditions is the fallback.'
)}
</p>
{config.tiers.map((tier, index) => (
<VisualTierCard
key={index}
tier={tier}
index={index}
total={config.tiers.length}
onChange={(next) => handleTierChange(index, next)}
onRemove={() => handleRemoveTier(index)}
onAddCondition={() => handleAddCondition(index)}
/>
))}
<Button
variant='outline'
size='sm'
className='h-9 w-36 justify-center'
onClick={handleAddTier}
>
<Plus className='mr-2 h-4 w-4' />
{t('Add tier')}
</Button>
</div>
)
}
// ---------------------------------------------------------------------------
// Raw expression editor
// ---------------------------------------------------------------------------
type RawExprEditorProps = {
exprString: string
onChange: (value: string) => void
}
function RawExprEditor({ exprString, onChange }: RawExprEditorProps) {
const { t } = useTranslation()
return (
<div className='space-y-3'>
<Alert>
<AlertDescription className='space-y-1 text-xs'>
<div>
{t('Variables')}: <code>len</code>, <code>p</code>, <code>c</code>,{' '}
<code>cr</code>, <code>cc</code>, <code>cc1h</code>,{' '}
<code>img</code>, <code>img_o</code>, <code>ai</code>,{' '}
<code>ao</code>
</div>
<div>
{t('Functions')}: <code>tier(name, value)</code>, <code>max</code>,{' '}
<code>min</code>, <code>ceil</code>, <code>floor</code>,{' '}
<code>abs</code>, <code>header(name)</code>,{' '}
<code>param(path)</code>, <code>has(source, text)</code>
</div>
</AlertDescription>
</Alert>
<Textarea
value={exprString}
onChange={(event) => onChange(event.target.value)}
placeholder='tier("base", p * 3 + c * 15)'
rows={6}
className='font-mono text-xs'
spellCheck={false}
/>
</div>
)
}
// ---------------------------------------------------------------------------
// Request rule condition row
// ---------------------------------------------------------------------------
type RuleConditionRowProps = {
condition: RequestCondition
onChange: (next: RequestCondition) => void
onRemove: () => void
}
function RuleConditionRow({
condition,
onChange,
onRemove,
}: RuleConditionRowProps) {
const { t } = useTranslation()
const matchOptions = getRequestRuleMatchOptions(condition.source)
const getMatchLabel = (mode: string) => {
switch (mode) {
case MATCH_EQ:
return t('Equals')
case MATCH_CONTAINS:
return t('Contains')
case MATCH_EXISTS:
return t('Exists')
case MATCH_GT:
return t('Greater than')
case MATCH_GTE:
return t('Greater than or equal')
case MATCH_LT:
return t('Less than')
case MATCH_LTE:
return t('Less than or equal')
case MATCH_RANGE:
return t('Overnight range')
default:
return mode
}
}
const getTimeFuncLabel = (timeFunc: TimeFunc) => {
switch (timeFunc) {
case 'hour':
return t('Hour of day')
case 'minute':
return t('Minute')
case 'weekday':
return t('Weekday')
case 'month':
return t('Month number')
case 'day':
return t('Day of month')
default:
return timeFunc
}
}
const sourceLabel =
condition.source === SOURCE_PARAM
? t('Body param')
: condition.source === SOURCE_HEADER
? t('Header')
: t('Time')
const handleSourceChange = (source: string) => {
if (source === SOURCE_TIME) {
onChange(createEmptyTimeCondition())
} else if (source === SOURCE_HEADER || source === SOURCE_PARAM) {
onChange({
...createEmptyCondition(),
source: source as 'param' | 'header',
})
}
}
const handleModeChange = (mode: string) => {
onChange({ ...condition, mode } as RequestCondition)
}
const renderTimeCondition = (timeCond: TimeCondition) => (
<>
<Select
value={timeCond.timeFunc}
onValueChange={(value) =>
onChange({ ...timeCond, timeFunc: value as TimeFunc })
}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue>{getTimeFuncLabel(timeCond.timeFunc)}</SelectValue>
</SelectTrigger>
<SelectContent>
{TIME_FUNCS.map((fn) => (
<SelectItem key={fn} value={fn}>
{getTimeFuncLabel(fn)}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={timeCond.timezone}
onValueChange={(value) =>
value !== null && onChange({ ...timeCond, timezone: value })
}
>
<SelectTrigger className='w-56' size='sm'>
<SelectValue>
{COMMON_TIMEZONES.find((tz) => tz.value === timeCond.timezone)
?.label ?? timeCond.timezone}
</SelectValue>
</SelectTrigger>
<SelectContent>
{COMMON_TIMEZONES.map((tz) => (
<SelectItem key={tz.value} value={tz.value}>
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={timeCond.mode}
onValueChange={(v) => v !== null && handleModeChange(v)}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue>{getMatchLabel(timeCond.mode)}</SelectValue>
</SelectTrigger>
<SelectContent>
{matchOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{getMatchLabel(option.value)}
</SelectItem>
))}
</SelectContent>
</Select>
{timeCond.mode === MATCH_RANGE ? (
<>
<DraftNumberInput
value={timeCond.rangeStart}
onValueChange={(value) =>
onChange({ ...timeCond, rangeStart: String(value) })
}
placeholder={t('Start')}
className='w-20'
/>
<span className='text-muted-foreground text-xs'>~</span>
<DraftNumberInput
value={timeCond.rangeEnd}
onValueChange={(value) =>
onChange({ ...timeCond, rangeEnd: String(value) })
}
placeholder={t('End')}
className='w-20'
/>
</>
) : (
<DraftNumberInput
value={timeCond.value}
onValueChange={(value) =>
onChange({ ...timeCond, value: String(value) })
}
placeholder={t('Value')}
className='w-24'
/>
)}
</>
)
const renderParamHeaderCondition = (phCond: ParamHeaderCondition) => (
<>
<Input
value={phCond.path}
onChange={(event) => onChange({ ...phCond, path: event.target.value })}
placeholder={
phCond.source === SOURCE_HEADER ? 'X-Header-Name' : 'service_tier'
}
className='w-44'
/>
<Select
value={phCond.mode}
onValueChange={(v) => v !== null && handleModeChange(v)}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue>{getMatchLabel(phCond.mode)}</SelectValue>
</SelectTrigger>
<SelectContent>
{matchOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{getMatchLabel(option.value)}
</SelectItem>
))}
</SelectContent>
</Select>
{phCond.mode !== MATCH_EXISTS && (
<Input
value={phCond.value}
onChange={(event) =>
onChange({ ...phCond, value: event.target.value })
}
placeholder={t('Value')}
className='w-44'
/>
)}
</>
)
return (
<div className='flex flex-wrap items-center gap-2'>
<Select
value={condition.source}
onValueChange={(v) => v !== null && handleSourceChange(v)}
>
<SelectTrigger className='w-28' size='sm'>
<SelectValue>{sourceLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={SOURCE_PARAM}>{t('Body param')}</SelectItem>
<SelectItem value={SOURCE_HEADER}>{t('Header')}</SelectItem>
<SelectItem value={SOURCE_TIME}>{t('Time')}</SelectItem>
</SelectContent>
</Select>
{condition.source === SOURCE_TIME
? renderTimeCondition(condition as TimeCondition)
: renderParamHeaderCondition(condition as ParamHeaderCondition)}
<Button
variant='ghost'
size='icon'
onClick={onRemove}
aria-label={t('Remove condition')}
className='ml-auto'
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
)
}
// ---------------------------------------------------------------------------
// Request rule group card
// ---------------------------------------------------------------------------
type RuleGroupCardProps = {
group: RequestRuleGroup
index: number
onChange: (next: RequestRuleGroup) => void
onRemove: () => void
}
function RuleGroupCard({
group,
index,
onChange,
onRemove,
}: RuleGroupCardProps) {
const { t } = useTranslation()
const handleConditionChange = (
conditionIndex: number,
next: RequestCondition
) => {
const conditions = [...group.conditions]
conditions[conditionIndex] = next
onChange({ ...group, conditions })
}
const handleAddCondition = (timeMode: boolean) => {
onChange({
...group,
conditions: [
...group.conditions,
timeMode ? createEmptyTimeCondition() : createEmptyCondition(),
],
})
}
return (
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2'>
<Badge variant='outline'>
{t('Rule group')} #{index + 1}
</Badge>
<Button
variant='ghost'
size='icon'
onClick={onRemove}
aria-label={t('Remove rule group')}
>
<Trash2 className='text-destructive h-4 w-4' />
</Button>
</div>
<div className='space-y-2'>
{group.conditions.map((condition, conditionIndex) => (
<RuleConditionRow
key={conditionIndex}
condition={condition}
onChange={(next) => handleConditionChange(conditionIndex, next)}
onRemove={() =>
onChange({
...group,
conditions: group.conditions.filter(
(_, i) => i !== conditionIndex
),
})
}
/>
))}
<div className='flex flex-wrap gap-2'>
<Button
variant='ghost'
size='sm'
onClick={() => handleAddCondition(false)}
>
<Plus className='mr-1 h-3 w-3' />
{t('Add param/header')}
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleAddCondition(true)}
>
<Plus className='mr-1 h-3 w-3' />
{t('Add time condition')}
</Button>
</div>
</div>
<div className='flex items-center gap-2'>
<Label className='text-xs'>{t('Multiplier')}</Label>
<DraftNumberInput
min={0}
step={0.01}
value={group.multiplier}
onValueChange={(value) =>
onChange({ ...group, multiplier: String(value) })
}
className='w-32'
placeholder='1.0'
/>
<span className='text-muted-foreground text-xs'>
{t('Final cost = base × multiplier when conditions match')}
</span>
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Preset section
// ---------------------------------------------------------------------------
type PresetSectionProps = {
applyPreset: (preset: Preset) => void
}
function PresetSection({ applyPreset }: PresetSectionProps) {
const { t } = useTranslation()
const [expanded, setExpanded] = useState(false)
const visible = expanded ? PRESET_GROUPS : PRESET_GROUPS.slice(0, 2)
const hasMore = PRESET_GROUPS.length > 2
return (
<div className='space-y-2'>
<div className='flex items-center gap-2'>
<span className='text-muted-foreground text-xs'>
{t('Preset templates')}
</span>
{hasMore && (
<Button
variant='ghost'
size='sm'
className='h-6 px-2 text-xs'
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? t('Collapse') : t('More templates...')}
</Button>
)}
</div>
<div className='space-y-1'>
{visible.map((presetGroup) => (
<div
key={presetGroup.group}
className='flex flex-wrap items-center gap-2'
>
<Badge variant='secondary' className='min-w-[60px] justify-center'>
{t(presetGroup.group)}
</Badge>
{presetGroup.presets.map((preset) => (
<Button
key={preset.key}
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => applyPreset(preset)}
>
{preset.label}
</Button>
))}
</div>
))}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Cost estimator
// ---------------------------------------------------------------------------
type EstimatorProps = {
effectiveExpr: string
}
function CostEstimator({ effectiveExpr }: EstimatorProps) {
const { t } = useTranslation()
const [promptTokens, setPromptTokens] = useState(0)
const [completionTokens, setCompletionTokens] = useState(0)
const [extras, setExtras] = useState<ExtraTokenValues>({
cacheReadTokens: 0,
cacheCreateTokens: 0,
cacheCreate1hTokens: 0,
imageTokens: 0,
imageOutputTokens: 0,
audioInputTokens: 0,
audioOutputTokens: 0,
})
const usesExtras = useMemo(
() => exprUsesExtraVars(effectiveExpr),
[effectiveExpr]
)
const result = useMemo(
() =>
evalExprLocally(effectiveExpr, promptTokens, completionTokens, extras),
[effectiveExpr, promptTokens, completionTokens, extras]
)
return (
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
<div className='space-y-1'>
<h4 className='text-sm font-medium'>{t('Token estimator')}</h4>
<p className='text-muted-foreground text-xs'>
{t(
'Enter token counts to preview the estimated cost (excluding group multipliers).'
)}
</p>
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<Label className='text-xs'>{t('Input tokens')}</Label>
<DraftNumberInput
min={0}
value={promptTokens}
onValueChange={setPromptTokens}
/>
</div>
<div className='space-y-1'>
<Label className='text-xs'>{t('Output tokens')}</Label>
<DraftNumberInput
min={0}
value={completionTokens}
onValueChange={setCompletionTokens}
/>
</div>
</div>
{usesExtras && (
<div className='grid grid-cols-2 gap-3'>
{BILLING_EXTRA_VARS.map((variable) => {
// BILLING_EXTRA_VARS only contains pricing variables; they are
// guaranteed to have a non-null `field` (the `len` condition-only
// variable is filtered out). Narrow the type here for safety.
if (!variable.field) return null
const stateKey = variable.field.replace(
'Price',
'Tokens'
) as keyof ExtraTokenValues
return (
<div key={variable.key} className='space-y-1'>
<Label className='text-xs'>{t(variable.shortLabel)}</Label>
<DraftNumberInput
min={0}
value={extras[stateKey]}
onValueChange={(value) =>
setExtras((prev) => ({
...prev,
[stateKey]: value,
}))
}
/>
</div>
)
})}
</div>
)}
<div
className={cn(
'rounded-md border p-3 text-sm',
result.error
? 'border-destructive/50 bg-destructive/10 text-destructive'
: 'border-primary/50 bg-primary/10'
)}
>
{result.error ? (
<span>
{t('Expression error')}: {result.error}
</span>
) : (
<div className='flex items-center gap-2'>
<span className='font-medium'>
{t('Estimated quota cost')}: {result.cost.toLocaleString()}
</span>
{result.matchedTier && (
<Badge variant='outline' className='text-xs'>
{t('Hit tier')}: {result.matchedTier}
</Badge>
)}
</div>
)}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// LLM prompt helper
// ---------------------------------------------------------------------------
const LLM_PROMPT_TEMPLATE = `You are an AI API billing expression design assistant. The user needs help designing a billing expression for an AI API gateway.
## Expression Language
Expressions are based on standard arithmetic with ternary operators.
### Token Variables
Input side:
- p — input token count (for pricing). Automatically excludes sub-categories priced separately (e.g., if cr is used, cache tokens are deducted from p)
- len — total input context length (for condition checks). Not affected by auto-exclusion; always reflects the full input length. Use in tier conditions
- cr — cache-hit (read) token count
- cc — cache-create token count (5-min TTL)
- cc1h — cache-create token count (1-hour TTL, Claude-specific)
- img — image input token count
- ai — audio input token count
Output side:
- c — output token count. Also auto-excludes sub-categories priced separately
- img_o — image output token count
- ao — audio output token count
### p/c Auto-exclusion
p and c are fallback variables representing all tokens not separately priced in the expression. If the expression uses a sub-category variable (e.g., cr), those tokens are deducted from p to avoid double-billing. Unused sub-category tokens remain in p/c at base price.
Important: len is NOT affected by auto-exclusion. Tier conditions should use len instead of p to prevent cache hits from lowering p and misidentifying the tier.
### Built-in Functions
- tier(name, value) — labels the billing tier; must wrap the cost expression
- max(a, b), min(a, b) — maximum/minimum
- ceil(x), floor(x), abs(x) — ceiling, floor, absolute value
- header(name) — reads a request header
- param(path) — reads a request body JSON path (gjson syntax)
- has(source, substr) — substring check
- hour(tz), minute(tz), weekday(tz), month(tz), day(tz) — time functions, tz is a timezone like "Asia/Shanghai"
### Price Coefficients
Numbers in the expression are $/1M tokens prices. For example, p * 2.5 means input $2.50/1M tokens.
## Expression Examples
Simple pricing:
tier("base", p * 2.5 + c * 15)
With cache:
tier("base", p * 2.5 + c * 15 + cr * 0.25)
Multi-tier (use len for conditions):
len <= 200000
? tier("standard", p * 3 + c * 15 + cr * 0.3 + cc * 3.75 + cc1h * 6)
: tier("long_context", p * 6 + c * 22.5 + cr * 0.6 + cc * 7.5 + cc1h * 12)
Image model:
tier("base", p * 2 + c * 8 + img * 2.5)
Multimodal with audio:
tier("base", p * 0.43 + c * 3.06 + img * 0.78 + ai * 3.81 + ao * 15.11)
Three-tier example:
len <= 128000
? tier("standard", p * 1.1 + c * 4.4)
: (len <= 1000000
? tier("medium", p * 2.2 + c * 8.8)
: tier("long", p * 4.4 + c * 17.6))
## Rules
1. Every leaf branch must be wrapped in tier("name", cost_expr)
2. Use English tier names, e.g. "base", "standard", "long_context"
3. Use len for tier conditions (not p), supports <, <=, >, >=
4. Multi-tier uses nested ternary: cond1 ? tier(...) : (cond2 ? tier(...) : tier(...))
5. Price coefficients are the provider's official $/1M tokens prices
6. If cache/image/audio don't need separate pricing, omit those variables; their tokens are included in p/c automatically
Please generate a billing expression based on the model information and pricing requirements provided.`
type LlmPromptHelperProps = {
modelName?: string
}
function LlmPromptHelper({ modelName }: LlmPromptHelperProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const prompt = useMemo(() => {
if (modelName) {
return LLM_PROMPT_TEMPLATE + `\n\nCurrent model: ${modelName}`
}
return LLM_PROMPT_TEMPLATE
}, [modelName])
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(prompt)
toast.success(t('Copied to clipboard'))
} catch {
toast.error(t('Failed to copy'))
}
}, [prompt, t])
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger
render={
<Button variant='ghost' size='sm' className='h-7 px-2 text-xs' />
}
>
<Copy className='mr-1.5 h-3 w-3' />
{t('LLM prompt helper')}
</CollapsibleTrigger>
<CollapsibleContent className='mt-2'>
<div className='bg-muted/30 rounded-md border p-3'>
<div className='mb-2 flex items-center justify-between'>
<p className='text-muted-foreground text-xs'>
{t(
'Copy this prompt and send it to an LLM (e.g. ChatGPT / Claude) to help design your billing expression.'
)}
</p>
<Button
variant='outline'
size='sm'
className='ml-3 shrink-0'
onClick={handleCopy}
>
<Copy className='mr-1.5 h-3 w-3' />
{t('Copy prompt')}
</Button>
</div>
<Textarea
value={prompt}
readOnly
rows={8}
className='font-mono text-xs'
spellCheck={false}
/>
</div>
</CollapsibleContent>
</Collapsible>
)
}
// ---------------------------------------------------------------------------
// Main editor
// ---------------------------------------------------------------------------
export type TieredPricingEditorProps = {
modelName?: string
billingExpr: string
requestRuleExpr: string
onBillingExprChange: (next: string) => void
onRequestRuleExprChange: (next: string) => void
}
type EditorMode = 'visual' | 'raw'
export const TieredPricingEditor = memo(function TieredPricingEditor({
modelName,
billingExpr: currentExpr,
requestRuleExpr: currentRequestRuleExpr,
onBillingExprChange,
onRequestRuleExprChange,
}: TieredPricingEditorProps) {
const { t } = useTranslation()
const [editorMode, setEditorMode] = useState<EditorMode>('visual')
const [visualConfig, setVisualConfig] = useState<VisualConfig | null>(() =>
tryParseVisualConfig(currentExpr)
)
const [rawExpr, setRawExpr] = useState(() =>
combineBillingExpr(currentExpr || '', currentRequestRuleExpr || '')
)
const [requestRuleGroups, setRequestRuleGroups] = useState<
RequestRuleGroup[]
>(() => tryParseRequestRuleExpr(currentRequestRuleExpr) || [])
const initRef = useRef(false)
useEffect(() => {
if (initRef.current) return
initRef.current = true
const parsedConfig = tryParseVisualConfig(currentExpr)
if (parsedConfig) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setVisualConfig(parsedConfig)
setEditorMode('visual')
} else if (currentExpr) {
setVisualConfig(null)
setEditorMode('raw')
} else {
setVisualConfig(createDefaultVisualConfig())
}
setRawExpr(
combineBillingExpr(currentExpr || '', currentRequestRuleExpr || '')
)
setRequestRuleGroups(tryParseRequestRuleExpr(currentRequestRuleExpr) || [])
}, [currentExpr, currentRequestRuleExpr])
useEffect(() => {
initRef.current = false
}, [modelName])
const canUseVisualRules = useMemo(() => {
if (!currentRequestRuleExpr) return true
return tryParseRequestRuleExpr(currentRequestRuleExpr) !== null
}, [currentRequestRuleExpr])
const effectiveExpr = useMemo(() => {
if (editorMode === 'visual') {
return generateExprFromVisualConfig(visualConfig)
}
const { billingExpr } = splitBillingExprAndRequestRules(rawExpr)
return billingExpr
}, [editorMode, visualConfig, rawExpr])
useEffect(() => {
if (effectiveExpr !== currentExpr) {
onBillingExprChange(effectiveExpr)
}
}, [effectiveExpr, currentExpr, onBillingExprChange])
useEffect(() => {
if (editorMode !== 'visual') return
const ruleExpr = buildRequestRuleExpr(requestRuleGroups)
if (ruleExpr !== currentRequestRuleExpr) {
onRequestRuleExprChange(ruleExpr)
}
}, [
editorMode,
requestRuleGroups,
currentRequestRuleExpr,
onRequestRuleExprChange,
])
const handleVisualChange = useCallback((next: VisualConfig) => {
setVisualConfig(next)
}, [])
const handleRawChange = useCallback(
(value: string) => {
setRawExpr(value)
const { requestRuleExpr: ruleStr } =
splitBillingExprAndRequestRules(value)
onRequestRuleExprChange(ruleStr)
},
[onRequestRuleExprChange]
)
const handleModeChange = useCallback(
(next: EditorMode) => {
if (next === 'visual') {
const { billingExpr, requestRuleExpr: ruleStr } =
splitBillingExprAndRequestRules(rawExpr)
const parsed = tryParseVisualConfig(billingExpr)
if (parsed) {
setVisualConfig(parsed)
} else {
setVisualConfig(createDefaultVisualConfig())
}
const parsedGroups = tryParseRequestRuleExpr(ruleStr)
setRequestRuleGroups(parsedGroups || [])
onRequestRuleExprChange(ruleStr)
} else {
const expr = generateExprFromVisualConfig(visualConfig)
const ruleExpr = buildRequestRuleExpr(requestRuleGroups)
setRawExpr(combineBillingExpr(expr, ruleExpr) || expr)
}
setEditorMode(next)
},
[rawExpr, visualConfig, requestRuleGroups, onRequestRuleExprChange]
)
const applyPreset = useCallback(
(preset: Preset) => {
const presetGroups = preset.requestRules || []
const ruleExpr = buildRequestRuleExpr(presetGroups)
const combined = combineBillingExpr(preset.expr, ruleExpr) || preset.expr
setRawExpr(combined)
const parsed = tryParseVisualConfig(preset.expr)
if (parsed) {
setVisualConfig(parsed)
setEditorMode('visual')
} else {
setEditorMode('raw')
setVisualConfig(null)
}
setRequestRuleGroups(presetGroups)
onRequestRuleExprChange(ruleExpr)
},
[onRequestRuleExprChange]
)
const handleRuleGroupsChange = useCallback((next: RequestRuleGroup[]) => {
setRequestRuleGroups(next)
}, [])
return (
<div className='space-y-4'>
<div className='flex items-center justify-between gap-2'>
<Label className='text-xs'>{t('Editor mode')}</Label>
<Select
value={editorMode}
onValueChange={(value) => handleModeChange(value as EditorMode)}
>
<SelectTrigger className='w-44' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value='visual'>{t('Visual editor')}</SelectItem>
<SelectItem value='raw'>{t('Expression editor')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className='flex flex-wrap items-start gap-x-4 gap-y-1'>
<div className='flex-1'>
<PresetSection applyPreset={applyPreset} />
</div>
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
</div>
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
{editorMode === 'visual' ? (
<VisualEditor
visualConfig={visualConfig}
onChange={handleVisualChange}
/>
) : (
<RawExprEditor exprString={rawExpr} onChange={handleRawChange} />
)}
{editorMode === 'visual' && (
<div className='space-y-3 border-t pt-3'>
<div className='space-y-1'>
<h4 className='text-sm font-medium'>
{t('Request rule pricing')}
</h4>
<p className='text-muted-foreground text-xs'>
{t(
'When conditions match, the final price is multiplied by X. Multiple matches multiply together; values < 1 act as discounts.'
)}
</p>
</div>
{currentRequestRuleExpr && !canUseVisualRules ? (
<Alert>
<AlertDescription className='text-xs'>
{t(
'This expression is too complex for the visual editor. Please switch to expression mode to edit.'
)}
</AlertDescription>
</Alert>
) : (
<>
{requestRuleGroups.map((group, groupIndex) => (
<RuleGroupCard
key={groupIndex}
group={group}
index={groupIndex}
onChange={(next) => {
const updated = [...requestRuleGroups]
updated[groupIndex] = next
handleRuleGroupsChange(updated)
}}
onRemove={() =>
handleRuleGroupsChange(
requestRuleGroups.filter((_, i) => i !== groupIndex)
)
}
/>
))}
<Button
variant='outline'
size='sm'
className='h-9 w-36 justify-center'
onClick={() =>
handleRuleGroupsChange([
...requestRuleGroups,
createEmptyRuleGroup(),
])
}
>
<Plus className='mr-2 h-4 w-4' />
{t('Add rule group')}
</Button>
</>
)}
</div>
)}
</div>
<CostEstimator effectiveExpr={effectiveExpr} />
</div>
)
})