/* Copyright (C) 2025 QuantumNous This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . For commercial licensing, please contact support@quantumnous.com */ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Banner, Button, Card, Collapsible, Input, InputNumber, Radio, RadioGroup, Select, Tag, TextArea, Typography, } from '@douyinfe/semi-ui'; import { IconDelete, IconPlus } from '@douyinfe/semi-icons'; import { renderQuota } from '../../../../helpers/render'; import { BILLING_EXTRA_VARS, BILLING_CACHE_VAR_MAP } from '../../../../constants'; import { createEmptyCondition, createEmptyTimeCondition, createEmptyRuleGroup, createEmptyTimeRuleGroup, getRequestRuleMatchOptions, normalizeCondition, tryParseRequestRuleExpr, buildRequestRuleExpr, combineBillingExpr, splitBillingExprAndRequestRules, MATCH_EQ, MATCH_EXISTS, MATCH_CONTAINS, MATCH_RANGE, MATCH_GTE, SOURCE_HEADER, SOURCE_PARAM, SOURCE_TIME, TIME_FUNCS, COMMON_TIMEZONES, } from './requestRuleExpr'; const { Text } = Typography; const PRICE_SUFFIX = '$/1M tokens'; function unitCostToPrice(uc) { return Number(uc) || 0; } function priceToUnitCost(price) { return Number(price) || 0; } const OPS = ['<', '<=', '>', '>=']; const VAR_OPTIONS = [ { value: 'p', label: 'p (输入)' }, { value: 'c', label: 'c (输出)' }, ]; const CACHE_MODE_TIMED = 'timed'; const CACHE_MODE_GENERIC = 'generic'; function formatTokenHint(n) { if (n == null || n === '' || Number.isNaN(Number(n))) return ''; const v = Number(n); if (v === 0) return '= 0'; if (v >= 1000000) return `= ${(v / 1000000).toLocaleString()}M tokens`; if (v >= 1000) return `= ${(v / 1000).toLocaleString()}K tokens`; return `= ${v.toLocaleString()} tokens`; } // --------------------------------------------------------------------------- // Expr generation from visual config (multi-condition) // --------------------------------------------------------------------------- function buildConditionStr(conditions) { if (!conditions || conditions.length === 0) return ''; return conditions .filter((c) => c.var && c.op && c.value != null && c.value !== '') .map((c) => `${c.var} ${c.op} ${c.value}`) .join(' && '); } const CACHE_VAR_MAP = BILLING_CACHE_VAR_MAP; function getTierCacheMode(tier) { if (tier?.cache_mode === CACHE_MODE_TIMED) { return CACHE_MODE_TIMED; } if (tier?.cache_mode === CACHE_MODE_GENERIC) { return CACHE_MODE_GENERIC; } return Number(tier?.cache_create_1h_unit_cost) > 0 ? CACHE_MODE_TIMED : CACHE_MODE_GENERIC; } function normalizeVisualTier(tier = {}) { return { ...tier, conditions: Array.isArray(tier.conditions) ? tier.conditions : [], cache_mode: getTierCacheMode(tier), }; } function createDefaultVisualConfig() { return { tiers: [ normalizeVisualTier({ conditions: [], input_unit_cost: 0, output_unit_cost: 0, label: 'base', cache_mode: CACHE_MODE_GENERIC, }), ], }; } function normalizeVisualConfig(config) { if (!config || !Array.isArray(config.tiers) || config.tiers.length === 0) { return createDefaultVisualConfig(); } return { ...config, tiers: config.tiers.map((tier) => normalizeVisualTier(tier)), }; } function buildTierBodyExpr(tier) { const parts = []; const ic = Number(tier.input_unit_cost) || 0; const oc = Number(tier.output_unit_cost) || 0; parts.push(`p * ${ic}`); parts.push(`c * ${oc}`); for (const cv of CACHE_VAR_MAP) { const v = Number(tier[cv.field]) || 0; if (v !== 0) parts.push(`${cv.exprVar} * ${v}`); } return parts.join(' + '); } function generateExprFromVisualConfig(config) { if (!config || !config.tiers || config.tiers.length === 0) return 'p * 0 + c * 0'; const tiers = config.tiers; if (tiers.length === 1) { const t = tiers[0]; const label = t.label || 'default'; const body = `tier("${label}", ${buildTierBodyExpr(t)})`; const cond = buildConditionStr(t.conditions); if (cond) { return `${cond} ? ${body} : p * 0 + c * 0`; } return body; } const parts = []; for (let i = 0; i < tiers.length; i++) { const t = tiers[i]; const label = t.label || `第${i + 1}档`; const body = `tier("${label}", ${buildTierBodyExpr(t)})`; const cond = buildConditionStr(t.conditions); if (i < tiers.length - 1 && cond) { parts.push(`${cond} ? ${body}`); } else { parts.push(body); } } return parts.join(' : '); } // --------------------------------------------------------------------------- // Reverse-parse an Expr string back into visual config // --------------------------------------------------------------------------- function tryParseVisualConfig(exprStr) { if (!exprStr) return null; try { const versionMatch = exprStr.match(/^v\d+:([\s\S]*)$/); if (versionMatch) exprStr = versionMatch[1]; const cacheVarNames = CACHE_VAR_MAP.map((cv) => cv.exprVar); const optCacheStr = cacheVarNames .map((v) => `(?:\\s*\\+\\s*${v}\\s*\\*\\s*([\\d.eE+-]+))?`) .join(''); // Body pattern: p * X + c * Y [+ cr * A] [+ cc * B] [+ cc1h * C] const bodyPat = `p\\s*\\*\\s*([\\d.eE+-]+)\\s*\\+\\s*c\\s*\\*\\s*([\\d.eE+-]+)${optCacheStr}`; // Single-tier: tier("label", body) const singleRe = new RegExp(`^tier\\("([^"]*)",\\s*${bodyPat}\\)$`); const simple = exprStr.match(singleRe); if (simple) { const tier = { conditions: [], input_unit_cost: Number(simple[2]), output_unit_cost: Number(simple[3]), label: simple[1], }; CACHE_VAR_MAP.forEach((cv, i) => { const val = simple[4 + i]; if (val != null) tier[cv.field] = Number(val); }); return normalizeVisualConfig({ tiers: [normalizeVisualTier(tier)] }); } // Multi-tier: cond1 ? tier(body) : cond2 ? tier(body) : tier(body) const condGroup = `((?:(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)(?:\\s*&&\\s*(?:p|c)\\s*(?:<|<=|>|>=)\\s*[\\d.eE+]+)*)`; const tierRe = new RegExp( `(?:${condGroup}\\s*\\?\\s*)?tier\\("([^"]*)",\\s*${bodyPat}\\)`, 'g', ); const tiers = []; let match; while ((match = tierRe.exec(exprStr)) !== null) { const condStr = match[1] || ''; const conditions = []; if (condStr) { const condParts = condStr.split(/\s*&&\s*/); for (const cp of condParts) { const cm = cp.trim().match(/^(p|c)\s*(<|<=|>|>=)\s*([\d.eE+]+)$/); if (cm) { conditions.push({ var: cm[1], op: cm[2], value: Number(cm[3]) }); } } } const tier = { conditions, input_unit_cost: Number(match[3]), output_unit_cost: Number(match[4]), label: match[2], }; CACHE_VAR_MAP.forEach((cv, i) => { const val = match[5 + i]; if (val != null) tier[cv.field] = Number(val); }); tiers.push(normalizeVisualTier(tier)); } if (tiers.length === 0) return null; const cfg = normalizeVisualConfig({ tiers }); const regenerated = generateExprFromVisualConfig(cfg); if (regenerated.replace(/\s+/g, '') !== exprStr.replace(/\s+/g, '')) return null; return cfg; } catch { return null; } } // --------------------------------------------------------------------------- // Condition editor row // --------------------------------------------------------------------------- function ConditionRow({ cond, onChange, onRemove, t }) { const hint = formatTokenHint(cond.value); return (
onChange({ ...cond, value: val })} />
); } // --------------------------------------------------------------------------- // Price input that preserves intermediate text like "7." or "0.5" // --------------------------------------------------------------------------- function PriceInput({ unitCost, field, index, onUpdate, placeholder }) { const priceFromModel = unitCostToPrice(unitCost); const [text, setText] = useState(priceFromModel === 0 ? '' : String(priceFromModel)); useEffect(() => { const current = Number(text); if (text === '' && priceFromModel === 0) return; if (!Number.isNaN(current) && current === priceFromModel) return; setText(priceFromModel === 0 ? '' : String(priceFromModel)); }, [priceFromModel]); const handleChange = (val) => { setText(val); if (val === '') { onUpdate(index, field, 0); return; } const num = Number(val); if (!Number.isNaN(num)) { onUpdate(index, field, priceToUnitCost(num)); } }; return ( ); } // --------------------------------------------------------------------------- // Extended price block (cache fields) — collapsible per tier, with mode switch // --------------------------------------------------------------------------- const CACHE_FIELDS_TIMED = [ { field: 'cache_read_unit_cost', labelKey: '缓存读取价格' }, { field: 'cache_create_unit_cost', labelKey: '缓存创建价格(5分钟)' }, { field: 'cache_create_1h_unit_cost', labelKey: '缓存创建价格(1小时)' }, ]; const CACHE_FIELDS_GENERIC = [ { field: 'cache_read_unit_cost', labelKey: '缓存读取价格' }, { field: 'cache_create_unit_cost', labelKey: '缓存创建价格' }, ]; function ExtendedPriceBlock({ tier, index, onUpdate, t }) { const mediaFields = BILLING_EXTRA_VARS.filter((v) => v.group === 'media'); const hasAny = [...CACHE_FIELDS_TIMED, ...mediaFields.map((v) => v.tierField)].some( (f) => Number(tier[typeof f === 'string' ? f : f.field]) > 0, ); const [expanded, setExpanded] = useState(hasAny); const cacheMode = getTierCacheMode(tier); const handleCacheModeChange = (e) => { const mode = e.target.value; const patch = { cache_mode: mode }; if (mode === CACHE_MODE_GENERIC) { patch.cache_create_1h_unit_cost = 0; } onUpdate(index, patch); }; const activeFields = cacheMode === CACHE_MODE_TIMED ? CACHE_FIELDS_TIMED : CACHE_FIELDS_GENERIC; return (
{t('这些价格都是可选项,不填也可以。')}
{t('通用缓存')} {t('分时缓存 (Claude)')}
{activeFields.map((cf) => (
{t(cf.labelKey)}
))}
{t('图片/音频价格(可选)')}
{mediaFields.map((v) => ({ field: v.tierField, labelKey: v.label })).map((cf) => (
{t(cf.labelKey)}
))}
); } // --------------------------------------------------------------------------- // Visual Tier Card (multi-condition) // --------------------------------------------------------------------------- function VisualTierCard({ tier, index, isLast, isOnly, onUpdate, onRemove, t }) { const conditions = tier.conditions || []; const varLabel = { p: t('输入'), c: t('输出') }; const condSummary = useMemo(() => { if (conditions.length === 0) return t('无条件(兜底档)'); return conditions .filter((c) => c.var && c.op && c.value != null) .map((c) => `${varLabel[c.var] || c.var} ${c.op} ${formatTokenHint(c.value)}`) .join(' && '); }, [conditions, t]); const updateCondition = (ci, newCond) => { const next = conditions.map((c, i) => (i === ci ? newCond : c)); onUpdate(index, 'conditions', next); }; const removeCondition = (ci) => { onUpdate( index, 'conditions', conditions.filter((_, i) => i !== ci), ); }; const addCondition = () => { if (conditions.length >= 2) return; const usedVars = conditions.map((c) => c.var); const nextVar = usedVars.includes('p') ? 'c' : 'p'; onUpdate(index, 'conditions', [ ...conditions, { var: nextVar, op: '<', value: 200000 }, ]); }; return (
{t('第 {{n}} 档', { n: index + 1 })} {isLast && !isOnly ? ( {t('兜底档')} ) : null}
{!isOnly ? (
{/* Tier label */}
{t('档位名称')} onUpdate(index, 'label', val)} style={{ width: '100%', marginTop: 2 }} />
{/* Conditions */} {!isLast || isOnly ? (
{t('条件')} {conditions.map((cond, ci) => ( updateCondition(ci, nc)} onRemove={() => removeCondition(ci)} t={t} /> ))} {conditions.length < 2 && ( )}
) : (
{condSummary}
)} {/* Prices */}
{t('输入价格')}
{t('输出价格')}
{/* Extended prices (cache) — collapsible */}
); } // --------------------------------------------------------------------------- // Visual editor // --------------------------------------------------------------------------- function VisualEditor({ visualConfig, onChange, t }) { const config = normalizeVisualConfig(visualConfig); const tiers = config.tiers || []; const updateTier = (index, field, value) => { const patch = typeof field === 'string' ? { [field]: value } : { ...field }; const next = tiers.map((tier, i) => i === index ? normalizeVisualTier({ ...tier, ...patch }) : tier, ); onChange({ ...config, tiers: next }); }; const addTier = () => { const newTiers = [...tiers]; if ( newTiers.length > 0 && (!newTiers[newTiers.length - 1].conditions || newTiers[newTiers.length - 1].conditions.length === 0) ) { newTiers[newTiers.length - 1] = { ...newTiers[newTiers.length - 1], conditions: [{ var: 'p', op: '<', value: 200000 }], }; } newTiers.push({ conditions: [], input_unit_cost: 0, output_unit_cost: 0, label: `第${newTiers.length + 1}档`, cache_mode: CACHE_MODE_GENERIC, }); onChange({ ...config, tiers: newTiers }); }; const removeTier = (index) => { if (tiers.length <= 1) return; const next = tiers.filter((_, i) => i !== index); if (next.length > 0) { next[next.length - 1] = { ...next[next.length - 1], conditions: [], }; } onChange({ ...config, tiers: next }); }; return (
{tiers.map((tier, index) => ( ))}
); } // --------------------------------------------------------------------------- // Raw Expr editor with preset templates // --------------------------------------------------------------------------- const PRESET_GROUPS = [ { group: '固定价格', 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: 'p <= 272000 ? tier("standard", p * 2.5 + c * 15 + cr * 0.25) : tier("long_context", p * 5 + c * 22.5 + cr * 0.5)' }, ], }, { group: '阶梯计费', presets: [ { key: 'claude-sonnet', label: 'Claude Sonnet 4.5', expr: 'p <= 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: 'p <= 32000 ? tier("short", p * 1.2 + c * 6 + cr * 0.24 + cc * 1.5) : p <= 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: 'p < 32000 && c < 200 ? tier("short_output", p * 0.8 + c * 2 + cr * 0.16) : p < 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: 'p <= 32000 && c <= 200 ? tier("discount", p * 0.8 + c * 2 + cr * 0.16 + cc * 0.17) : p <= 32000 ? tier("short", p * 0.8 + c * 8 + cr * 0.16 + cc * 0.17) : p <= 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: '多模态', 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: '请求条件', 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, 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: 'p <= 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, path: 'service_tier', mode: MATCH_EQ, value: 'priority' }], multiplier: '2' }, { conditions: [{ source: SOURCE_PARAM, path: 'service_tier', mode: MATCH_EQ, value: 'flex' }], multiplier: '0.5' }, ], }, ], }, { group: '时间促销', presets: [ { key: 'night-discount', label: '夜间半价', expr: 'tier("base", p * 3 + c * 15)', requestRules: [{ conditions: [{ source: SOURCE_TIME, timeFunc: 'hour', timezone: 'Asia/Shanghai', mode: MATCH_RANGE, rangeStart: '21', rangeEnd: '6' }], multiplier: '0.5' }], }, { key: 'weekend-discount', label: '周末8折', expr: 'tier("base", p * 3 + c * 15)', requestRules: [ { conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '0' }], multiplier: '0.8' }, { conditions: [{ source: SOURCE_TIME, timeFunc: 'weekday', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '6' }], multiplier: '0.8' }, ], }, { key: 'new-year-promo', label: '新年促销', expr: 'tier("base", p * 3 + c * 15)', requestRules: [{ conditions: [ { source: SOURCE_TIME, timeFunc: 'month', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' }, { source: SOURCE_TIME, timeFunc: 'day', timezone: 'Asia/Shanghai', mode: MATCH_EQ, value: '1' }, ], multiplier: '0.5' }], }, ], }, ]; const PRESET_DEFAULT_VISIBLE = 2; function PresetSection({ applyPreset, t }) { const [expanded, setExpanded] = useState(false); const visibleGroups = expanded ? PRESET_GROUPS : PRESET_GROUPS.slice(0, PRESET_DEFAULT_VISIBLE); const hasMore = PRESET_GROUPS.length > PRESET_DEFAULT_VISIBLE; return (
{t('预设模板')} {hasMore && ( )}
{visibleGroups.map((g) => (
{t(g.group)} {g.presets.map((p) => ( ))}
))}
); } function RawExprEditor({ exprString, onChange, t }) { return (
{t('变量')}: p ({t('输入 Token')}), c ( {t('输出 Token')}), cr ({t('缓存读取')}),{' '} cc ({t('缓存创建')}),{' '} cc1h ({t('缓存创建-1小时')})
{t('函数')}: tier(name, value),{' '} max(a, b), min(a, b),{' '} ceil(x), floor(x),{' '} abs(x), header(name),{' '} param(path), has(source, text)
} style={{ marginBottom: 12 }} />