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, '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) => { const nextDraft = event.target.value setDraft(nextDraft) onValueChange(parseNumberDraft(nextDraft)) } const handleFocus = (event: FocusEvent) => { setFocused(true) onFocus?.(event) if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) { event.currentTarget.select() } } const handleMouseUp = (event: ReactMouseEvent) => { onMouseUp?.(event) if (selectZeroOnFocus && isZeroDraft(event.currentTarget.value)) { event.preventDefault() event.currentTarget.select() } } const handleBlur = (event: FocusEvent) => { const normalized = parseNumberDraft(event.currentTarget.value) setFocused(false) setDraft(String(normalized)) onValueChange(normalized) onBlur?.(event) } return ( ) } // --------------------------------------------------------------------------- // 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 (
onChange({ ...condition, value })} placeholder='tokens' className='w-32' /> {formatTokenHint(condition.value)}
) } // --------------------------------------------------------------------------- // Price input field // --------------------------------------------------------------------------- type PriceFieldProps = { label: string hint?: string value: number onChange: (next: number) => void } function PriceField({ label, hint, value, onChange }: PriceFieldProps) { return (
{hint &&

{hint}

}
) } // --------------------------------------------------------------------------- // 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 ( handlePriceChange(fieldKey, priceToUnitCost(next))} /> ) } return (
{t('Tier')} {index + 1} / {total} {tier.conditions.length === 0 && ( {t('Fallback tier')} )} onChange({ ...tier, label: event.target.value }) } placeholder={t('Tier name')} className='h-7 w-36' />
{/* Conditions */}
{tier.conditions.length === 0 ? (

{t('Always matches (default tier).')}

) : ( tier.conditions.map((condition, conditionIndex) => ( handleConditionChange(conditionIndex, next)} onRemove={() => handleConditionRemove(conditionIndex)} /> )) )}
{PRICE_SUFFIX}
handlePriceChange('input_unit_cost', priceToUnitCost(value)) } /> handlePriceChange('output_unit_cost', priceToUnitCost(value)) } />
value !== null && handleCacheModeChange(value as CacheMode) } > {t('Generic cache')} {t('Time-sliced cache (Claude)')}
{CACHE_PRICE_VARS.map((variable) => { if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) { return null } return renderPriceVariable(variable) })}
{/* Media prices */}
{mediaOpen && (
{MEDIA_PRICE_VARS.map(renderPriceVariable)}
)}
) } // --------------------------------------------------------------------------- // 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 (

{t( 'Each tier supports up to 2 conditions. The last tier without conditions is the fallback.' )}

{config.tiers.map((tier, index) => ( handleTierChange(index, next)} onRemove={() => handleRemoveTier(index)} onAddCondition={() => handleAddCondition(index)} /> ))}
) } // --------------------------------------------------------------------------- // Raw expression editor // --------------------------------------------------------------------------- type RawExprEditorProps = { exprString: string onChange: (value: string) => void } function RawExprEditor({ exprString, onChange }: RawExprEditorProps) { const { t } = useTranslation() return (
{t('Variables')}: len, p, c,{' '} cr, cc, cc1h,{' '} img, img_o, ai,{' '} ao
{t('Functions')}: tier(name, value), max,{' '} min, ceil, floor,{' '} abs, header(name),{' '} param(path), has(source, text)