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.
This commit is contained in:
CaIon
2026-05-06 16:24:06 +08:00
parent 9acf5fecae
commit 0f9f094a48
62 changed files with 3655 additions and 2343 deletions
+20 -1
View File
@@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { Button } from '@/components/ui/button'
const FEEDBACK_URL = 'https://github.com/QuantumNous/new-api/issues'
type GeneralErrorProps = React.HTMLAttributes<HTMLDivElement> & {
minimal?: boolean
}
@@ -28,10 +30,27 @@ export function GeneralError({
{t('Please try again later.')}
</p>
{!minimal && (
<div className='mt-6 flex gap-4'>
<p className='text-muted-foreground text-center text-sm'>
{t('If this keeps happening, please report it on GitHub Issues.')}
</p>
)}
{!minimal && (
<div className='mt-6 flex flex-wrap justify-center gap-4'>
<Button variant='outline' onClick={() => history.go(-1)}>
{t('Go Back')}
</Button>
<Button
variant='outline'
render={
<a
href={FEEDBACK_URL}
target='_blank'
rel='noopener noreferrer'
/>
}
>
{t('Report an issue')}
</Button>
<Button onClick={() => navigate({ to: '/' })}>
{t('Back to Home')}
</Button>
@@ -87,7 +87,10 @@ export function DeploymentAccessGuard({
const navigate = useNavigate()
const handleGoToSettings = () => {
navigate({ to: '/system-settings/integrations' })
navigate({
to: '/system-settings/models/$section',
params: { section: 'model-deployment' },
})
}
// Combined loading state with step indicator
@@ -168,6 +168,13 @@ export function ModelMutateDrawer({
'group_ratio_setting.group_special_usable_group': '{}',
'grok.violation_deduction_enabled': false,
'grok.violation_deduction_amount': 0,
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.max_entries': 100000,
'channel_affinity_setting.default_ttl_seconds': 3600,
'channel_affinity_setting.rules': '[]',
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
}
return getOptionValue(systemOptionsData.data, defaultModelSettings)
}, [systemOptionsData])
@@ -0,0 +1,102 @@
import { SettingsPage } from '../components/settings-page'
import type { BillingSettings } from '../types'
import {
BILLING_DEFAULT_SECTION,
getBillingSectionContent,
} from './section-registry.tsx'
const defaultBillingSettings: BillingSettings = {
QuotaForNewUser: 0,
PreConsumedQuota: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
TopUpLink: '',
'general_setting.docs_link': '',
'quota_setting.enable_free_model_pre_consume': true,
QuotaPerUnit: 500000,
USDExchangeRate: 7,
'general_setting.quota_display_type': 'USD',
'general_setting.custom_currency_symbol': '¤',
'general_setting.custom_currency_exchange_rate': 1,
DisplayInCurrencyEnabled: true,
DisplayTokenStatEnabled: true,
ModelPrice: '',
ModelRatio: '',
CacheRatio: '',
CreateCacheRatio: '',
CompletionRatio: '',
ImageRatio: '',
AudioRatio: '',
AudioCompletionRatio: '',
ExposeRatioEnabled: false,
'billing_setting.billing_mode': '{}',
'billing_setting.billing_expr': '{}',
'tool_price_setting.prices': '{}',
TopupGroupRatio: '',
GroupRatio: '',
UserUsableGroups: '',
GroupGroupRatio: '',
AutoGroups: '',
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
PayAddress: '',
EpayId: '',
EpayKey: '',
Price: 7.3,
MinTopUp: 1,
CustomCallbackAddress: '',
PayMethods: '',
'payment_setting.amount_options': '',
'payment_setting.amount_discount': '',
StripeApiSecret: '',
StripeWebhookSecret: '',
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
CreemApiKey: '',
CreemWebhookSecret: '',
CreemTestMode: false,
CreemProducts: '[]',
WaffoEnabled: false,
WaffoApiKey: '',
WaffoPrivateKey: '',
WaffoPublicCert: '',
WaffoSandboxPublicCert: '',
WaffoSandboxApiKey: '',
WaffoSandboxPrivateKey: '',
WaffoSandbox: false,
WaffoMerchantId: '',
WaffoCurrency: 'USD',
WaffoUnitPrice: 1,
WaffoMinTopUp: 1,
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
WaffoPayMethods: '[]',
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1,
WaffoPancakeMinTopUp: 1,
'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000,
}
export function BillingSettings() {
return (
<SettingsPage
routePath='/_authenticated/system-settings/billing/$section'
defaultSettings={defaultBillingSettings}
defaultSection={BILLING_DEFAULT_SECTION}
getSectionContent={getBillingSectionContent}
/>
)
}
@@ -0,0 +1,202 @@
import { parseCurrencyDisplayType } from '@/lib/currency'
import type { BillingSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { CheckinSettingsSection } from '../general/checkin-settings-section'
import { PricingSection } from '../general/pricing-section'
import { QuotaSettingsSection } from '../general/quota-settings-section'
import { PaymentSettingsSection } from '../integrations/payment-settings-section'
import { RatioSettingsCard } from '../models/ratio-settings-card'
const getModelDefaults = (settings: BillingSettings) => ({
ModelPrice: settings.ModelPrice,
ModelRatio: settings.ModelRatio,
CacheRatio: settings.CacheRatio,
CreateCacheRatio: settings.CreateCacheRatio,
CompletionRatio: settings.CompletionRatio,
ImageRatio: settings.ImageRatio,
AudioRatio: settings.AudioRatio,
AudioCompletionRatio: settings.AudioCompletionRatio,
ExposeRatioEnabled: settings.ExposeRatioEnabled,
BillingMode: settings['billing_setting.billing_mode'],
BillingExpr: settings['billing_setting.billing_expr'],
})
const getGroupDefaults = (settings: BillingSettings) => ({
TopupGroupRatio: settings.TopupGroupRatio,
GroupRatio: settings.GroupRatio,
UserUsableGroups: settings.UserUsableGroups,
GroupGroupRatio: settings.GroupGroupRatio,
AutoGroups: settings.AutoGroups,
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
GroupSpecialUsableGroup:
settings['group_ratio_setting.group_special_usable_group'],
})
const BILLING_SECTIONS = [
{
id: 'quota',
titleKey: 'Quota Settings',
descriptionKey: 'Configure user quota allocation and rewards',
build: (settings: BillingSettings) => (
<QuotaSettingsSection
defaultValues={{
QuotaForNewUser: settings.QuotaForNewUser,
PreConsumedQuota: settings.PreConsumedQuota,
QuotaForInviter: settings.QuotaForInviter,
QuotaForInvitee: settings.QuotaForInvitee,
TopUpLink: settings.TopUpLink,
'general_setting.docs_link': settings['general_setting.docs_link'],
'quota_setting.enable_free_model_pre_consume':
settings['quota_setting.enable_free_model_pre_consume'],
}}
/>
),
},
{
id: 'currency',
titleKey: 'Currency & Display',
descriptionKey: 'Configure currency conversion and quota display options',
build: (settings: BillingSettings) => (
<PricingSection
defaultValues={{
QuotaPerUnit: settings.QuotaPerUnit,
USDExchangeRate: settings.USDExchangeRate,
DisplayInCurrencyEnabled: settings.DisplayInCurrencyEnabled,
DisplayTokenStatEnabled: settings.DisplayTokenStatEnabled,
general_setting: {
quota_display_type: parseCurrencyDisplayType(
settings['general_setting.quota_display_type']
),
custom_currency_symbol:
settings['general_setting.custom_currency_symbol'] ?? '¤',
custom_currency_exchange_rate:
settings['general_setting.custom_currency_exchange_rate'] ?? 1,
},
}}
/>
),
},
{
id: 'model-pricing',
titleKey: 'Model Pricing',
descriptionKey: 'Configure model pricing ratios and tool prices',
build: (settings: BillingSettings) => (
<RatioSettingsCard
titleKey='Model Pricing'
descriptionKey='Configure model pricing ratios and tool prices'
modelDefaults={getModelDefaults(settings)}
groupDefaults={getGroupDefaults(settings)}
toolPricesDefault={settings['tool_price_setting.prices']}
visibleTabs={['models', 'tool-prices', 'upstream-sync']}
/>
),
},
{
id: 'group-pricing',
titleKey: 'Group Pricing',
descriptionKey: 'Configure group ratios and group-specific pricing rules',
build: (settings: BillingSettings) => (
<RatioSettingsCard
titleKey='Group Pricing'
descriptionKey='Configure group ratios and group-specific pricing rules'
modelDefaults={getModelDefaults(settings)}
groupDefaults={getGroupDefaults(settings)}
toolPricesDefault={settings['tool_price_setting.prices']}
visibleTabs={['groups']}
/>
),
},
{
id: 'payment',
titleKey: 'Payment Gateway',
descriptionKey: 'Configure payment gateway integrations',
build: (settings: BillingSettings) => (
<PaymentSettingsSection
defaultValues={{
PayAddress: settings.PayAddress,
EpayId: settings.EpayId,
EpayKey: settings.EpayKey,
Price: settings.Price,
MinTopUp: settings.MinTopUp,
CustomCallbackAddress: settings.CustomCallbackAddress,
PayMethods: settings.PayMethods,
AmountOptions: settings['payment_setting.amount_options'],
AmountDiscount: settings['payment_setting.amount_discount'],
StripeApiSecret: settings.StripeApiSecret,
StripeWebhookSecret: settings.StripeWebhookSecret,
StripePriceId: settings.StripePriceId,
StripeUnitPrice: settings.StripeUnitPrice,
StripeMinTopUp: settings.StripeMinTopUp,
StripePromotionCodesEnabled: settings.StripePromotionCodesEnabled,
CreemApiKey: settings.CreemApiKey,
CreemWebhookSecret: settings.CreemWebhookSecret,
CreemTestMode: settings.CreemTestMode,
CreemProducts: settings.CreemProducts,
}}
waffoDefaultValues={{
WaffoEnabled: settings.WaffoEnabled ?? false,
WaffoApiKey: settings.WaffoApiKey ?? '',
WaffoPrivateKey: settings.WaffoPrivateKey ?? '',
WaffoPublicCert: settings.WaffoPublicCert ?? '',
WaffoSandboxPublicCert: settings.WaffoSandboxPublicCert ?? '',
WaffoSandboxApiKey: settings.WaffoSandboxApiKey ?? '',
WaffoSandboxPrivateKey: settings.WaffoSandboxPrivateKey ?? '',
WaffoSandbox: settings.WaffoSandbox ?? false,
WaffoMerchantId: settings.WaffoMerchantId ?? '',
WaffoCurrency: settings.WaffoCurrency ?? 'USD',
WaffoUnitPrice: settings.WaffoUnitPrice ?? 1,
WaffoMinTopUp: settings.WaffoMinTopUp ?? 1,
WaffoNotifyUrl: settings.WaffoNotifyUrl ?? '',
WaffoReturnUrl: settings.WaffoReturnUrl ?? '',
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
}}
waffoPancakeDefaultValues={{
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
WaffoPancakeWebhookPublicKey:
settings.WaffoPancakeWebhookPublicKey ?? '',
WaffoPancakeWebhookTestKey:
settings.WaffoPancakeWebhookTestKey ?? '',
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
}}
/>
),
},
{
id: 'checkin',
titleKey: 'Check-in Rewards',
descriptionKey: 'Configure daily check-in rewards for users',
build: (settings: BillingSettings) => (
<CheckinSettingsSection
defaultValues={{
enabled: settings['checkin_setting.enabled'],
minQuota: settings['checkin_setting.min_quota'],
maxQuota: settings['checkin_setting.max_quota'],
}}
/>
),
},
] as const
export type BillingSectionId = (typeof BILLING_SECTIONS)[number]['id']
const billingRegistry = createSectionRegistry<BillingSectionId, BillingSettings>(
{
sections: BILLING_SECTIONS,
defaultSection: 'quota',
basePath: '/system-settings/billing',
urlStyle: 'path',
}
)
export const BILLING_SECTION_IDS = billingRegistry.sectionIds
export const BILLING_DEFAULT_SECTION = billingRegistry.defaultSection
export const getBillingSectionNavItems = billingRegistry.getSectionNavItems
export const getBillingSectionContent = billingRegistry.getSectionContent
@@ -1,89 +0,0 @@
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { parseCurrencyDisplayType } from '@/lib/currency'
import { useSystemOptions, getOptionValue } from '../hooks/use-system-options'
import type { GeneralSettings } from '../types'
import {
GENERAL_DEFAULT_SECTION,
getGeneralSectionContent,
} from './section-registry.tsx'
const defaultGeneralSettings: GeneralSettings = {
'theme.frontend': 'default',
Notice: '',
SystemName: 'New API',
Logo: '',
Footer: '',
About: '',
HomePageContent: '',
ServerAddress: '',
'legal.user_agreement': '',
'legal.privacy_policy': '',
QuotaForNewUser: 0,
PreConsumedQuota: 0,
QuotaForInviter: 0,
QuotaForInvitee: 0,
TopUpLink: '',
'general_setting.docs_link': '',
'quota_setting.enable_free_model_pre_consume': true,
QuotaPerUnit: 500000,
USDExchangeRate: 7,
'general_setting.quota_display_type': 'USD',
'general_setting.custom_currency_symbol': '¤',
'general_setting.custom_currency_exchange_rate': 1,
RetryTimes: 0,
DisplayInCurrencyEnabled: true,
DisplayTokenStatEnabled: true,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
'checkin_setting.enabled': false,
'checkin_setting.min_quota': 1000,
'checkin_setting.max_quota': 10000,
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.max_entries': 100000,
'channel_affinity_setting.default_ttl_seconds': 3600,
'channel_affinity_setting.rules': '[]',
}
export function GeneralSettings() {
const { t } = useTranslation()
const { data, isLoading } = useSystemOptions()
const params = useParams({
from: '/_authenticated/system-settings/general/$section',
})
if (isLoading) {
return (
<div className='flex items-center justify-center py-12'>
<div className='text-muted-foreground'>{t('Loading settings...')}</div>
</div>
)
}
const settings = getOptionValue(data?.data, defaultGeneralSettings)
const quotaDisplayType = parseCurrencyDisplayType(
settings['general_setting.quota_display_type']
)
const activeSection = (params?.section ?? GENERAL_DEFAULT_SECTION) as
| 'system-info'
| 'quota'
| 'pricing'
| 'checkin'
| 'behavior'
| 'channel-affinity'
const sectionContent = getGeneralSectionContent(
activeSection,
settings,
quotaDisplayType
)
return (
<div className='flex h-full w-full flex-1 flex-col'>
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
<div className='space-y-4'>{sectionContent}</div>
</div>
</div>
)
}
@@ -1,148 +0,0 @@
import type { GeneralSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { ChannelAffinitySection } from './channel-affinity'
import { CheckinSettingsSection } from './checkin-settings-section'
import { PricingSection } from './pricing-section'
import { QuotaSettingsSection } from './quota-settings-section'
import { SystemBehaviorSection } from './system-behavior-section'
import { SystemInfoSection } from './system-info-section'
const GENERAL_SECTIONS = [
{
id: 'system-info',
titleKey: 'System Information',
descriptionKey: 'Configure basic system information and branding',
build: (settings: GeneralSettings) => (
<SystemInfoSection
defaultValues={{
theme: {
frontend: settings['theme.frontend'] as 'default' | 'classic',
},
Notice: settings.Notice,
SystemName: settings.SystemName,
Logo: settings.Logo,
Footer: settings.Footer,
About: settings.About,
HomePageContent: settings.HomePageContent,
ServerAddress: settings.ServerAddress,
legal: {
user_agreement: settings['legal.user_agreement'],
privacy_policy: settings['legal.privacy_policy'],
},
}}
/>
),
},
{
id: 'quota',
titleKey: 'Quota Settings',
descriptionKey: 'Configure user quota allocation and rewards',
build: (settings: GeneralSettings) => (
<QuotaSettingsSection
defaultValues={{
QuotaForNewUser: settings.QuotaForNewUser,
PreConsumedQuota: settings.PreConsumedQuota,
QuotaForInviter: settings.QuotaForInviter,
QuotaForInvitee: settings.QuotaForInvitee,
TopUpLink: settings.TopUpLink,
'general_setting.docs_link': settings['general_setting.docs_link'],
'quota_setting.enable_free_model_pre_consume':
settings['quota_setting.enable_free_model_pre_consume'],
}}
/>
),
},
{
id: 'pricing',
titleKey: 'Pricing & Display',
descriptionKey: 'Configure pricing model and display options',
build: (
settings: GeneralSettings,
quotaDisplayType: 'USD' | 'CNY' | 'TOKENS' | 'CUSTOM'
) => (
<PricingSection
defaultValues={{
QuotaPerUnit: settings.QuotaPerUnit,
USDExchangeRate: settings.USDExchangeRate,
DisplayInCurrencyEnabled: settings.DisplayInCurrencyEnabled,
DisplayTokenStatEnabled: settings.DisplayTokenStatEnabled,
general_setting: {
quota_display_type: quotaDisplayType,
custom_currency_symbol:
settings['general_setting.custom_currency_symbol'] ?? '¤',
custom_currency_exchange_rate:
settings['general_setting.custom_currency_exchange_rate'] ?? 1,
},
}}
/>
),
},
{
id: 'checkin',
titleKey: 'Check-in Settings',
descriptionKey: 'Configure daily check-in rewards for users',
build: (settings: GeneralSettings) => (
<CheckinSettingsSection
defaultValues={{
enabled: settings['checkin_setting.enabled'],
minQuota: settings['checkin_setting.min_quota'],
maxQuota: settings['checkin_setting.max_quota'],
}}
/>
),
},
{
id: 'behavior',
titleKey: 'System Behavior',
descriptionKey: 'Configure system-wide behavior and defaults',
build: (settings: GeneralSettings) => (
<SystemBehaviorSection
defaultValues={{
RetryTimes: settings.RetryTimes,
DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
DemoSiteEnabled: settings.DemoSiteEnabled,
SelfUseModeEnabled: settings.SelfUseModeEnabled,
}}
/>
),
},
{
id: 'channel-affinity',
titleKey: 'Channel Affinity',
descriptionKey: 'Configure channel affinity (sticky routing) rules',
build: (settings: GeneralSettings) => (
<ChannelAffinitySection
defaultValues={{
'channel_affinity_setting.enabled':
settings['channel_affinity_setting.enabled'],
'channel_affinity_setting.switch_on_success':
settings['channel_affinity_setting.switch_on_success'],
'channel_affinity_setting.max_entries':
settings['channel_affinity_setting.max_entries'],
'channel_affinity_setting.default_ttl_seconds':
settings['channel_affinity_setting.default_ttl_seconds'],
'channel_affinity_setting.rules':
settings['channel_affinity_setting.rules'],
}}
/>
),
},
] as const
export type GeneralSectionId = (typeof GENERAL_SECTIONS)[number]['id']
const generalRegistry = createSectionRegistry<
GeneralSectionId,
GeneralSettings,
['USD' | 'CNY' | 'TOKENS' | 'CUSTOM']
>({
sections: GENERAL_SECTIONS,
defaultSection: 'system-info',
basePath: '/system-settings/general',
urlStyle: 'path',
})
export const GENERAL_SECTION_IDS = generalRegistry.sectionIds
export const GENERAL_DEFAULT_SECTION = generalRegistry.defaultSection
export const getGeneralSectionNavItems = generalRegistry.getSectionNavItems
export const getGeneralSectionContent = generalRegistry.getSectionContent
@@ -32,7 +32,6 @@ const _systemInfoSchema = z.object({
theme: z.object({
frontend: z.enum(['default', 'classic']),
}),
Notice: z.string().optional(),
SystemName: z.string().min(1),
ServerAddress: z.string().optional(),
Logo: z.string().url().optional().or(z.literal('')),
@@ -65,7 +64,6 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
frontend:
defaultValues.theme?.frontend === 'classic' ? 'classic' : 'default',
},
Notice: normalizeValue(defaultValues.Notice),
SystemName: normalizeValue(defaultValues.SystemName),
ServerAddress: normalizeValue(defaultValues.ServerAddress),
Logo: normalizeValue(defaultValues.Logo),
@@ -82,7 +80,6 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
theme: z.object({
frontend: z.enum(['default', 'classic']),
}),
Notice: z.string().optional(),
SystemName: z.string().min(1, {
error: () => t('System name is required'),
}),
@@ -161,31 +158,6 @@ export function SystemInfoSection({ defaultValues }: SystemInfoSectionProps) {
)}
/>
<FormField
control={form.control}
name='Notice'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Notice')}</FormLabel>
<FormControl>
<Textarea
placeholder={t(
'Enter announcement content (supports Markdown & HTML)'
)}
rows={6}
{...field}
/>
</FormControl>
<FormDescription>
{t(
'Announcement displayed to users (supports Markdown & HTML)'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='SystemName'
@@ -1,88 +0,0 @@
import { SettingsPage } from '../components/settings-page'
import type { IntegrationSettings as IntegrationSettingsType } from '../types'
import {
INTEGRATIONS_DEFAULT_SECTION,
getIntegrationsSectionContent,
} from './section-registry.tsx'
const defaultIntegrationSettings: IntegrationSettingsType = {
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
SMTPFrom: '',
SMTPToken: '',
SMTPSSLEnabled: false,
SMTPForceAuthLogin: false,
WorkerUrl: '',
WorkerValidKey: '',
WorkerAllowHttpImageRequestEnabled: false,
ChannelDisableThreshold: '',
QuotaRemindThreshold: '',
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
PayAddress: '',
EpayId: '',
EpayKey: '',
Price: 7.3,
MinTopUp: 1,
CustomCallbackAddress: '',
PayMethods: '',
'payment_setting.amount_options': '',
'payment_setting.amount_discount': '',
StripeApiSecret: '',
StripeWebhookSecret: '',
StripePriceId: '',
StripeUnitPrice: 8.0,
StripeMinTopUp: 1,
StripePromotionCodesEnabled: false,
CreemApiKey: '',
CreemWebhookSecret: '',
CreemTestMode: false,
CreemProducts: '[]',
WaffoEnabled: false,
WaffoApiKey: '',
WaffoPrivateKey: '',
WaffoPublicCert: '',
WaffoSandboxPublicCert: '',
WaffoSandboxApiKey: '',
WaffoSandboxPrivateKey: '',
WaffoSandbox: false,
WaffoMerchantId: '',
WaffoCurrency: 'USD',
WaffoUnitPrice: 1,
WaffoMinTopUp: 1,
WaffoNotifyUrl: '',
WaffoReturnUrl: '',
WaffoPayMethods: '[]',
WaffoPancakeEnabled: false,
WaffoPancakeSandbox: false,
WaffoPancakeMerchantID: '',
WaffoPancakePrivateKey: '',
WaffoPancakeWebhookPublicKey: '',
WaffoPancakeWebhookTestKey: '',
WaffoPancakeStoreID: '',
WaffoPancakeProductID: '',
WaffoPancakeReturnURL: '',
WaffoPancakeCurrency: 'USD',
WaffoPancakeUnitPrice: 1,
WaffoPancakeMinTopUp: 1,
}
export function IntegrationSettings() {
return (
<SettingsPage
routePath='/_authenticated/system-settings/integrations/$section'
defaultSettings={defaultIntegrationSettings}
defaultSection={INTEGRATIONS_DEFAULT_SECTION}
getSectionContent={getIntegrationsSectionContent}
/>
)
}
@@ -1,160 +0,0 @@
import type { IntegrationSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { EmailSettingsSection } from './email-settings-section'
import { IoNetDeploymentSettingsSection } from './ionet-deployment-settings-section'
import { MonitoringSettingsSection } from './monitoring-settings-section'
import { PaymentSettingsSection } from './payment-settings-section'
import { WorkerSettingsSection } from './worker-settings-section'
const INTEGRATIONS_SECTIONS = [
{
id: 'payment',
titleKey: 'Payment Gateway',
descriptionKey: 'Configure payment gateway integrations',
build: (settings: IntegrationSettings) => (
<PaymentSettingsSection
defaultValues={{
PayAddress: settings.PayAddress,
EpayId: settings.EpayId,
EpayKey: settings.EpayKey,
Price: settings.Price,
MinTopUp: settings.MinTopUp,
CustomCallbackAddress: settings.CustomCallbackAddress,
PayMethods: settings.PayMethods,
AmountOptions: settings['payment_setting.amount_options'],
AmountDiscount: settings['payment_setting.amount_discount'],
StripeApiSecret: settings.StripeApiSecret,
StripeWebhookSecret: settings.StripeWebhookSecret,
StripePriceId: settings.StripePriceId,
StripeUnitPrice: settings.StripeUnitPrice,
StripeMinTopUp: settings.StripeMinTopUp,
StripePromotionCodesEnabled: settings.StripePromotionCodesEnabled,
CreemApiKey: settings.CreemApiKey,
CreemWebhookSecret: settings.CreemWebhookSecret,
CreemTestMode: settings.CreemTestMode,
CreemProducts: settings.CreemProducts,
}}
waffoDefaultValues={{
WaffoEnabled: settings.WaffoEnabled ?? false,
WaffoApiKey: settings.WaffoApiKey ?? '',
WaffoPrivateKey: settings.WaffoPrivateKey ?? '',
WaffoPublicCert: settings.WaffoPublicCert ?? '',
WaffoSandboxPublicCert: settings.WaffoSandboxPublicCert ?? '',
WaffoSandboxApiKey: settings.WaffoSandboxApiKey ?? '',
WaffoSandboxPrivateKey: settings.WaffoSandboxPrivateKey ?? '',
WaffoSandbox: settings.WaffoSandbox ?? false,
WaffoMerchantId: settings.WaffoMerchantId ?? '',
WaffoCurrency: settings.WaffoCurrency ?? 'USD',
WaffoUnitPrice: settings.WaffoUnitPrice ?? 1,
WaffoMinTopUp: settings.WaffoMinTopUp ?? 1,
WaffoNotifyUrl: settings.WaffoNotifyUrl ?? '',
WaffoReturnUrl: settings.WaffoReturnUrl ?? '',
WaffoPayMethods: settings.WaffoPayMethods ?? '[]',
}}
waffoPancakeDefaultValues={{
WaffoPancakeEnabled: settings.WaffoPancakeEnabled ?? false,
WaffoPancakeSandbox: settings.WaffoPancakeSandbox ?? false,
WaffoPancakeMerchantID: settings.WaffoPancakeMerchantID ?? '',
WaffoPancakePrivateKey: settings.WaffoPancakePrivateKey ?? '',
WaffoPancakeWebhookPublicKey:
settings.WaffoPancakeWebhookPublicKey ?? '',
WaffoPancakeWebhookTestKey: settings.WaffoPancakeWebhookTestKey ?? '',
WaffoPancakeStoreID: settings.WaffoPancakeStoreID ?? '',
WaffoPancakeProductID: settings.WaffoPancakeProductID ?? '',
WaffoPancakeReturnURL: settings.WaffoPancakeReturnURL ?? '',
WaffoPancakeCurrency: settings.WaffoPancakeCurrency ?? 'USD',
WaffoPancakeUnitPrice: settings.WaffoPancakeUnitPrice ?? 1,
WaffoPancakeMinTopUp: settings.WaffoPancakeMinTopUp ?? 1,
}}
/>
),
},
{
id: 'email',
titleKey: 'SMTP Email',
descriptionKey: 'Configure SMTP email settings',
build: (settings: IntegrationSettings) => (
<EmailSettingsSection
defaultValues={{
SMTPServer: settings.SMTPServer,
SMTPPort: settings.SMTPPort,
SMTPAccount: settings.SMTPAccount,
SMTPFrom: settings.SMTPFrom,
SMTPToken: settings.SMTPToken,
SMTPSSLEnabled: settings.SMTPSSLEnabled,
SMTPForceAuthLogin: settings.SMTPForceAuthLogin,
}}
/>
),
},
{
id: 'worker',
titleKey: 'Worker Proxy',
descriptionKey: 'Configure worker service settings',
build: (settings: IntegrationSettings) => (
<WorkerSettingsSection
defaultValues={{
WorkerUrl: settings.WorkerUrl,
WorkerValidKey: settings.WorkerValidKey,
WorkerAllowHttpImageRequestEnabled:
settings.WorkerAllowHttpImageRequestEnabled,
}}
/>
),
},
{
id: 'ionet',
titleKey: 'io.net Deployments',
descriptionKey: 'Configure IoNet model deployment settings',
build: (settings: IntegrationSettings) => (
<IoNetDeploymentSettingsSection
defaultValues={{
enabled: settings['model_deployment.ionet.enabled'],
apiKey: settings['model_deployment.ionet.api_key'],
}}
/>
),
},
{
id: 'monitoring',
titleKey: 'Monitoring & Alerts',
descriptionKey: 'Configure channel monitoring and automation',
build: (settings: IntegrationSettings) => (
<MonitoringSettingsSection
defaultValues={{
ChannelDisableThreshold: settings.ChannelDisableThreshold,
QuotaRemindThreshold: settings.QuotaRemindThreshold,
AutomaticDisableChannelEnabled:
settings.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
'monitor_setting.auto_test_channel_enabled':
settings['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
settings['monitor_setting.auto_test_channel_minutes'],
}}
/>
),
},
] as const
export type IntegrationSectionId = (typeof INTEGRATIONS_SECTIONS)[number]['id']
const integrationsRegistry = createSectionRegistry<
IntegrationSectionId,
IntegrationSettings
>({
sections: INTEGRATIONS_SECTIONS,
defaultSection: 'payment',
basePath: '/system-settings/integrations',
urlStyle: 'path',
})
export const INTEGRATIONS_SECTION_IDS = integrationsRegistry.sectionIds
export const INTEGRATIONS_DEFAULT_SECTION = integrationsRegistry.defaultSection
export const getIntegrationsSectionNavItems =
integrationsRegistry.getSectionNavItems
export const getIntegrationsSectionContent =
integrationsRegistry.getSectionContent
@@ -1,5 +1,3 @@
import type { MaintenanceSettings } from '../types'
export type HeaderNavPricingConfig = {
enabled: boolean
requireAuth: boolean
@@ -62,25 +60,6 @@ export const SIDEBAR_MODULES_DEFAULT: SidebarModulesAdminConfig = {
},
}
export const DEFAULT_MAINTENANCE_SETTINGS: MaintenanceSettings = {
Notice: '',
LogConsumeEnabled: false,
HeaderNavModules: JSON.stringify(HEADER_NAV_DEFAULT),
SidebarModulesAdmin: JSON.stringify(SIDEBAR_MODULES_DEFAULT),
'performance_setting.disk_cache_enabled': false,
'performance_setting.disk_cache_threshold_mb': 10,
'performance_setting.disk_cache_max_size_mb': 1024,
'performance_setting.disk_cache_path': '',
'performance_setting.monitor_enabled': false,
'performance_setting.monitor_cpu_threshold': 90,
'performance_setting.monitor_memory_threshold': 90,
'performance_setting.monitor_disk_threshold': 95,
'perf_metrics_setting.enabled': true,
'perf_metrics_setting.flush_interval': 5,
'perf_metrics_setting.bucket_time': 'hour',
'perf_metrics_setting.retention_days': 0,
}
const toBoolean = (value: unknown, fallback: boolean): boolean => {
if (typeof value === 'boolean') return value
if (typeof value === 'number') return value === 1
@@ -1,54 +0,0 @@
import { useMemo } from 'react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
import { DEFAULT_MAINTENANCE_SETTINGS } from './config'
import {
MAINTENANCE_DEFAULT_SECTION,
getMaintenanceSectionContent,
} from './section-registry.tsx'
export function MaintenanceSettings() {
const { t } = useTranslation()
const { data, isLoading } = useSystemOptions()
const { status } = useStatus()
const params = useParams({
from: '/_authenticated/system-settings/maintenance/$section',
})
const settings = useMemo(
() => getOptionValue(data?.data, DEFAULT_MAINTENANCE_SETTINGS),
[data?.data]
)
if (isLoading) {
return (
<div className='text-muted-foreground flex h-full w-full flex-1 items-center justify-center'>
{t('Loading maintenance settings...')}
</div>
)
}
const activeSection = (params?.section ?? MAINTENANCE_DEFAULT_SECTION) as
| 'update-checker'
| 'notice'
| 'logs'
| 'header-navigation'
| 'sidebar-modules'
| 'performance'
const sectionContent = getMaintenanceSectionContent(
activeSection,
settings,
status?.version as string | undefined,
status?.start_time as number | null | undefined
)
return (
<div className='flex h-full w-full flex-1 flex-col'>
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
<div className='space-y-4'>{sectionContent}</div>
</div>
</div>
)
}
@@ -1,137 +0,0 @@
import type { MaintenanceSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import {
parseHeaderNavModules,
parseSidebarModulesAdmin,
serializeHeaderNavModules,
serializeSidebarModulesAdmin,
} from './config'
import { HeaderNavigationSection } from './header-navigation-section'
import { LogSettingsSection } from './log-settings-section'
import { NoticeSection } from './notice-section'
import { PerformanceSection } from './performance-section'
import { SidebarModulesSection } from './sidebar-modules-section'
import { UpdateCheckerSection } from './update-checker-section'
const MAINTENANCE_SECTIONS = [
{
id: 'update-checker',
titleKey: 'System maintenance',
descriptionKey: 'Check for system updates',
build: (
_settings: MaintenanceSettings,
currentVersion?: string | null,
startTime?: number | null
) => (
<UpdateCheckerSection
currentVersion={currentVersion}
startTime={startTime}
/>
),
},
{
id: 'notice',
titleKey: 'System Notice',
descriptionKey: 'Configure system maintenance notice',
build: (settings: MaintenanceSettings) => (
<NoticeSection defaultValue={settings.Notice ?? ''} />
),
},
{
id: 'logs',
titleKey: 'Log Maintenance',
descriptionKey: 'Configure log consumption settings',
build: (settings: MaintenanceSettings) => (
<LogSettingsSection
defaultEnabled={Boolean(settings.LogConsumeEnabled)}
/>
),
},
{
id: 'header-navigation',
titleKey: 'Header navigation',
descriptionKey: 'Configure header navigation modules',
build: (settings: MaintenanceSettings) => {
const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
return (
<HeaderNavigationSection
config={headerNavConfig}
initialSerialized={headerNavSerialized}
/>
)
},
},
{
id: 'sidebar-modules',
titleKey: 'Sidebar modules',
descriptionKey: 'Configure sidebar modules for admin',
build: (settings: MaintenanceSettings) => {
const sidebarConfig = parseSidebarModulesAdmin(
settings.SidebarModulesAdmin
)
const sidebarSerialized = serializeSidebarModulesAdmin(sidebarConfig)
return (
<SidebarModulesSection
config={sidebarConfig}
initialSerialized={sidebarSerialized}
/>
)
},
},
{
id: 'performance',
titleKey: 'Performance',
descriptionKey: 'Disk cache, system monitoring and performance stats',
build: (settings: MaintenanceSettings) => (
<PerformanceSection
defaultValues={{
'performance_setting.disk_cache_enabled':
settings['performance_setting.disk_cache_enabled'] ?? false,
'performance_setting.disk_cache_threshold_mb':
settings['performance_setting.disk_cache_threshold_mb'] ?? 10,
'performance_setting.disk_cache_max_size_mb':
settings['performance_setting.disk_cache_max_size_mb'] ?? 1024,
'performance_setting.disk_cache_path':
settings['performance_setting.disk_cache_path'] ?? '',
'performance_setting.monitor_enabled':
settings['performance_setting.monitor_enabled'] ?? false,
'performance_setting.monitor_cpu_threshold':
settings['performance_setting.monitor_cpu_threshold'] ?? 90,
'performance_setting.monitor_memory_threshold':
settings['performance_setting.monitor_memory_threshold'] ?? 90,
'performance_setting.monitor_disk_threshold':
settings['performance_setting.monitor_disk_threshold'] ?? 95,
'perf_metrics_setting.enabled':
settings['perf_metrics_setting.enabled'] ?? true,
'perf_metrics_setting.flush_interval':
settings['perf_metrics_setting.flush_interval'] ?? 5,
'perf_metrics_setting.bucket_time':
settings['perf_metrics_setting.bucket_time'] ?? 'hour',
'perf_metrics_setting.retention_days':
settings['perf_metrics_setting.retention_days'] ?? 0,
}}
/>
),
},
] as const
export type MaintenanceSectionId = (typeof MAINTENANCE_SECTIONS)[number]['id']
const maintenanceRegistry = createSectionRegistry<
MaintenanceSectionId,
MaintenanceSettings,
[string | null | undefined, number | null | undefined]
>({
sections: MAINTENANCE_SECTIONS,
defaultSection: 'update-checker',
basePath: '/system-settings/maintenance',
urlStyle: 'path',
})
export const MAINTENANCE_SECTION_IDS = maintenanceRegistry.sectionIds
export const MAINTENANCE_DEFAULT_SECTION = maintenanceRegistry.defaultSection
export const getMaintenanceSectionNavItems =
maintenanceRegistry.getSectionNavItems
export const getMaintenanceSectionContent =
maintenanceRegistry.getSectionContent
@@ -43,6 +43,13 @@ const defaultModelSettings: ModelSettings = {
AutoGroups: '',
DefaultUseAutoGroup: false,
'group_ratio_setting.group_special_usable_group': '{}',
'channel_affinity_setting.enabled': false,
'channel_affinity_setting.switch_on_success': true,
'channel_affinity_setting.max_entries': 100000,
'channel_affinity_setting.default_ttl_seconds': 3600,
'channel_affinity_setting.rules': '[]',
'model_deployment.ionet.api_key': '',
'model_deployment.ionet.enabled': false,
}
export function ModelSettings() {
File diff suppressed because it is too large Load Diff
@@ -1,656 +0,0 @@
import { useEffect, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { ChevronDown } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { Button } from '@/components/ui/button'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { TieredPricingEditor } from './tiered-pricing-editor'
const createModelDialogSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, t('Model name is required')),
price: z.string().optional(),
ratio: z.string().optional(),
cacheRatio: z.string().optional(),
createCacheRatio: z.string().optional(),
completionRatio: z.string().optional(),
imageRatio: z.string().optional(),
audioRatio: z.string().optional(),
audioCompletionRatio: z.string().optional(),
})
type ModelDialogFormValues = z.infer<ReturnType<typeof createModelDialogSchema>>
type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
type PricingSubMode = 'ratio' | 'price'
export type ModelRatioData = {
name: string
price?: string
ratio?: string
cacheRatio?: string
createCacheRatio?: string
completionRatio?: string
imageRatio?: string
audioRatio?: string
audioCompletionRatio?: string
billingMode?: 'per-token' | 'per-request' | 'tiered_expr'
billingExpr?: string
requestRuleExpr?: string
}
type ModelRatioDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (data: ModelRatioData) => void
editData?: ModelRatioData | null
}
export function ModelRatioDialog({
open,
onOpenChange,
onSave,
editData,
}: ModelRatioDialogProps) {
const { t } = useTranslation()
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
const [pricingSubMode, setPricingSubMode] = useState<PricingSubMode>('ratio')
const [advancedOpen, setAdvancedOpen] = useState(false)
const [promptPrice, setPromptPrice] = useState('')
const [completionPrice, setCompletionPrice] = useState('')
const [billingExpr, setBillingExpr] = useState('')
const [requestRuleExpr, setRequestRuleExpr] = useState('')
const isEditMode = !!editData
const form = useForm<ModelDialogFormValues>({
resolver: zodResolver(createModelDialogSchema(t)),
defaultValues: {
name: '',
price: '',
ratio: '',
cacheRatio: '',
createCacheRatio: '',
completionRatio: '',
imageRatio: '',
audioRatio: '',
audioCompletionRatio: '',
},
})
useEffect(() => {
if (editData) {
form.reset(editData)
// eslint-disable-next-line react-hooks/set-state-in-effect
setBillingExpr(editData.billingExpr || '')
setRequestRuleExpr(editData.requestRuleExpr || '')
if (editData.billingMode === 'tiered_expr') {
setPricingMode('tiered_expr')
} else if (editData.price && editData.price !== '') {
setPricingMode('per-request')
} else {
setPricingMode('per-token')
if (editData.ratio) {
const tokenPrice = parseFloat(editData.ratio) * 2
setPromptPrice(tokenPrice.toString())
if (editData.completionRatio) {
const compPrice = tokenPrice * parseFloat(editData.completionRatio)
setCompletionPrice(compPrice.toString())
}
}
}
} else {
form.reset({
name: '',
price: '',
ratio: '',
cacheRatio: '',
createCacheRatio: '',
completionRatio: '',
imageRatio: '',
audioRatio: '',
audioCompletionRatio: '',
})
setPricingMode('per-token')
setPricingSubMode('ratio')
setPromptPrice('')
setCompletionPrice('')
setBillingExpr('')
setRequestRuleExpr('')
setAdvancedOpen(false)
}
}, [editData, form, open])
const handleSubmit = (values: ModelDialogFormValues) => {
// Always pass through every field. The visual editor decides what to
// persist based on `billingMode`, and tiered_expr models also keep the
// ratio/price values as fallback during multi-instance sync delays
// (the backend's ModelPriceHelper checks billing_mode first, so these
// fallbacks only kick in when billing_setting hasn't propagated yet).
const data: ModelRatioData = {
name: values.name,
billingMode: pricingMode,
price: values.price || '',
ratio: values.ratio || '',
cacheRatio: values.cacheRatio || '',
createCacheRatio: values.createCacheRatio || '',
completionRatio: values.completionRatio || '',
imageRatio: values.imageRatio || '',
audioRatio: values.audioRatio || '',
audioCompletionRatio: values.audioCompletionRatio || '',
}
if (pricingMode === 'tiered_expr') {
data.billingExpr = billingExpr
data.requestRuleExpr = requestRuleExpr
}
onSave(data)
form.reset()
onOpenChange(false)
}
const validateNumber = (value: string) => {
if (value === '') return true
return !isNaN(parseFloat(value))
}
const handlePromptPriceChange = (value: string) => {
setPromptPrice(value)
if (value && !isNaN(parseFloat(value))) {
const ratio = parseFloat(value) / 2
form.setValue('ratio', ratio.toString())
} else {
form.setValue('ratio', '')
}
}
const handleCompletionPriceChange = (value: string) => {
setCompletionPrice(value)
if (
value &&
!isNaN(parseFloat(value)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice)) &&
parseFloat(promptPrice) > 0
) {
const completionRatio = parseFloat(value) / parseFloat(promptPrice)
form.setValue('completionRatio', completionRatio.toString())
} else {
form.setValue('completionRatio', '')
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-h-[90vh] overflow-y-auto sm:max-w-[680px]'>
<DialogHeader>
<DialogTitle>
{isEditMode ? t('Edit model') : t('Add model')}
</DialogTitle>
<DialogDescription>
{t('Configure pricing ratios for a specific model.')}
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form
onSubmit={form.handleSubmit(handleSubmit)}
className='space-y-6'
autoComplete='off'
>
<FormField
control={form.control}
name='name'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model name')}</FormLabel>
<FormControl>
<Input
placeholder={t('gpt-4')}
{...field}
disabled={isEditMode}
/>
</FormControl>
<FormDescription>
{t('The exact model identifier as used in API requests.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className='space-y-4'>
<Label>{t('Pricing mode')}</Label>
<RadioGroup
value={pricingMode}
onValueChange={(value) => setPricingMode(value as PricingMode)}
>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='per-token' id='per-token' />
<Label htmlFor='per-token' className='font-normal'>
{t('Per-token (ratio based)')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='per-request' id='per-request' />
<Label htmlFor='per-request' className='font-normal'>
{t('Per-request (fixed price)')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='tiered_expr' id='tiered_expr' />
<Label htmlFor='tiered_expr' className='font-normal'>
{t('Tiered (billing expression)')}
</Label>
</div>
</RadioGroup>
</div>
{pricingMode === 'tiered_expr' ? (
<TieredPricingEditor
modelName={form.getValues('name')}
billingExpr={billingExpr}
requestRuleExpr={requestRuleExpr}
onBillingExprChange={setBillingExpr}
onRequestRuleExprChange={setRequestRuleExpr}
/>
) : pricingMode === 'per-request' ? (
<FormField
control={form.control}
name='price'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Fixed price (USD)')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='0.01'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Cost in USD per request, regardless of tokens used.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
<div className='space-y-4'>
<Label>{t('Input mode')}</Label>
<RadioGroup
value={pricingSubMode}
onValueChange={(value) =>
setPricingSubMode(value as PricingSubMode)
}
>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='ratio' id='ratio' />
<Label htmlFor='ratio' className='font-normal'>
{t('Ratio mode')}
</Label>
</div>
<div className='flex items-center space-x-2'>
<RadioGroupItem value='price' id='price' />
<Label htmlFor='price' className='font-normal'>
{t('Price mode (USD per 1M tokens)')}
</Label>
</div>
</RadioGroup>
</div>
{pricingSubMode === 'ratio' ? (
<>
<FormField
control={form.control}
name='ratio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Model ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
if (value) {
setPromptPrice(
(parseFloat(value) * 2).toString()
)
} else {
setPromptPrice('')
}
}
}}
/>
</FormControl>
<FormDescription>
{field.value && !isNaN(parseFloat(field.value))
? t(
'Calculated price: ${{price}} per 1M tokens',
{
price: (
parseFloat(field.value) * 2
).toFixed(4),
}
)
: t('Multiplier for prompt tokens.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='completionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Completion ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
const ratio = form.getValues('ratio')
if (value && ratio) {
const compPrice =
parseFloat(ratio) * 2 * parseFloat(value)
setCompletionPrice(compPrice.toString())
} else {
setCompletionPrice('')
}
}
}}
/>
</FormControl>
<FormDescription>
{field.value &&
!isNaN(parseFloat(field.value)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice))
? t(
'Calculated price: ${{price}} per 1M tokens',
{
price: (
parseFloat(promptPrice) *
parseFloat(field.value)
).toFixed(4),
}
)
: t('Multiplier for completion tokens.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<div className='space-y-4'>
<div className='space-y-2'>
<Label>{t('Prompt price ($/1M tokens)')}</Label>
<Input
type='text'
placeholder='2.0'
value={promptPrice}
onChange={(e) =>
handlePromptPriceChange(e.target.value)
}
/>
<p className='text-muted-foreground text-sm'>
{promptPrice && !isNaN(parseFloat(promptPrice))
? t('Calculated ratio: {{ratio}}', {
ratio: (parseFloat(promptPrice) / 2).toFixed(4),
})
: t('Enter Input price to calculate ratio')}
</p>
</div>
<div className='space-y-2'>
<Label>{t('Completion price ($/1M tokens)')}</Label>
<Input
type='text'
placeholder='4.0'
value={completionPrice}
onChange={(e) =>
handleCompletionPriceChange(e.target.value)
}
/>
<p className='text-muted-foreground text-sm'>
{completionPrice &&
!isNaN(parseFloat(completionPrice)) &&
promptPrice &&
!isNaN(parseFloat(promptPrice)) &&
parseFloat(promptPrice) > 0
? t('Calculated ratio: {{ratio}}', {
ratio: (
parseFloat(completionPrice) /
parseFloat(promptPrice)
).toFixed(4),
})
: t('Enter Completion price to calculate ratio')}
</p>
</div>
</div>
</>
)}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger
render={
<Button
type='button'
variant='outline'
className='flex w-full items-center justify-between'
/>
}
>
{t('Advanced options')}
<ChevronDown
className={`h-4 w-4 transition-transform duration-200 ${
advancedOpen ? 'rotate-180' : ''
}`}
/>
</CollapsibleTrigger>
<CollapsibleContent className='space-y-6 pt-6'>
<FormField
control={form.control}
name='cacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Cache ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='0.1'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Discount ratio for cache hits.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='createCacheRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Create cache ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.25'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t(
'Ratio applied when creating cache entries for supported models.'
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='imageRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Image ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for image processing.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='audioRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for audio inputs.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name='audioCompletionRatio'
render={({ field }) => (
<FormItem>
<FormLabel>{t('Audio completion ratio')}</FormLabel>
<FormControl>
<Input
type='text'
placeholder='1.0'
{...field}
onChange={(e) => {
const value = e.target.value
if (validateNumber(value)) {
field.onChange(value)
}
}}
/>
</FormControl>
<FormDescription>
{t('Multiplier for audio outputs.')}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CollapsibleContent>
</Collapsible>
</>
)}
<DialogFooter>
<Button
type='button'
variant='outline'
onClick={() => onOpenChange(false)}
>
{t('Cancel')}
</Button>
<Button type='submit'>
{isEditMode ? t('Update') : t('Add')}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
)
}
@@ -1,19 +1,27 @@
import { useState, useMemo, memo, useCallback, useEffect } from 'react'
import {
type ColumnDef,
type ColumnFiltersState,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type VisibilityState,
type SortingState,
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
getPaginationRowModel,
useReactTable,
} from '@tanstack/react-table'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useMediaQuery } from '@/hooks'
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import {
Table,
TableBody,
@@ -23,6 +31,7 @@ import {
TableRow,
} from '@/components/ui/table'
import {
DataTableBulkActions,
DataTableColumnHeader,
DataTableToolbar,
DataTablePagination,
@@ -33,7 +42,11 @@ import {
splitBillingExprAndRequestRules,
} from '@/features/pricing/lib/billing-expr'
import { safeJsonParse } from '../utils/json-parser'
import { ModelRatioDialog, type ModelRatioData } from './model-ratio-dialog'
import {
ModelPricingEditorPanel,
ModelPricingSheet,
type ModelRatioData,
} from './model-pricing-sheet'
type ModelRatioVisualEditorProps = {
modelPrice: string
@@ -67,9 +80,101 @@ type ModelRow = {
const STORAGE_KEY = 'model-ratio-column-visibility'
const formatValue = (value?: string) => {
if (!value || value === '') return '—'
return value
const hasValue = (value?: string) => value !== undefined && value !== ''
const toNumberOrNull = (value?: string) => {
if (!hasValue(value)) return null
const num = Number(value)
return Number.isFinite(num) ? num : null
}
const formatPrice = (value: number) => {
return Number.parseFloat(value.toFixed(12)).toString()
}
const ratioToPrice = (ratio?: string, denominator?: string) => {
const ratioNumber = toNumberOrNull(ratio)
const denominatorNumber = denominator ? toNumberOrNull(denominator) : 2
if (ratioNumber === null || denominatorNumber === null) return ''
return formatPrice(ratioNumber * denominatorNumber)
}
const filterBySelectedValues = (
rowValue: unknown,
filterValue: unknown
): boolean => {
if (!Array.isArray(filterValue) || filterValue.length === 0) return true
return filterValue.includes(String(rowValue))
}
const getModeLabel = (mode?: string) => {
if (mode === 'per-request') return 'Per-request'
if (mode === 'tiered_expr') return 'Expression'
return 'Per-token'
}
const getModeVariant = (mode?: string): 'warning' | 'info' | 'success' => {
if (mode === 'per-request') return 'warning'
if (mode === 'tiered_expr') return 'info'
return 'success'
}
const getExpressionSummary = (row: ModelRow, t: (key: string) => string) => {
const tierCount = (row.billingExpr?.match(/tier\(/g) || []).length
if (tierCount > 0) {
return `${t('Tiered pricing')} · ${tierCount} ${t('tiers')}`
}
return t('Expression pricing')
}
const getPriceSummary = (row: ModelRow, t: (key: string) => string) => {
if (row.billingMode === 'tiered_expr') {
return getExpressionSummary(row, t)
}
if (row.billingMode === 'per-request') {
return row.price ? `$${row.price} / ${t('request')}` : t('Unset price')
}
const inputPrice = ratioToPrice(row.ratio)
if (!inputPrice) return t('Unset price')
const extraCount = [
row.completionRatio,
row.cacheRatio,
row.createCacheRatio,
row.imageRatio,
row.audioRatio,
row.audioCompletionRatio,
].filter(hasValue).length
return extraCount > 0
? `${t('Input')} $${inputPrice} · ${extraCount} ${t('extras')}`
: `${t('Input')} $${inputPrice}`
}
const getPriceDetail = (row: ModelRow, t: (key: string) => string) => {
if (row.billingMode === 'tiered_expr') {
return row.requestRuleExpr
? t('Includes request rules')
: t('Expression based')
}
if (row.billingMode === 'per-request') {
return t('Fixed request price')
}
const inputPrice = ratioToPrice(row.ratio)
if (!inputPrice) return t('No base input price')
const details = [
row.completionRatio &&
`${t('Output')} $${ratioToPrice(row.completionRatio, inputPrice)}`,
row.cacheRatio &&
`${t('Cache')} $${ratioToPrice(row.cacheRatio, inputPrice)}`,
row.createCacheRatio &&
`${t('Cache write')} $${ratioToPrice(row.createCacheRatio, inputPrice)}`,
].filter(Boolean)
return details.length > 0 ? details.join(' · ') : t('Base input price only')
}
export const ModelRatioVisualEditor = memo(
@@ -87,12 +192,17 @@ export const ModelRatioVisualEditor = memo(
onChange,
}: ModelRatioVisualEditorProps) {
const { t } = useTranslation()
const [dialogOpen, setDialogOpen] = useState(false)
const isMobile = useMediaQuery('(max-width: 767px)')
const [sheetOpen, setSheetOpen] = useState(false)
const [editorOpen, setEditorOpen] = useState(false)
const [editData, setEditData] = useState<ModelRatioData | null>(null)
const [sorting, setSorting] = useState<SortingState>([])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = useState('')
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
pageSize: 20,
})
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
() => {
@@ -102,6 +212,7 @@ export const ModelRatioVisualEditor = memo(
return safeJsonParse<VisibilityState>(saved, {
fallback: {
cacheRatio: false,
createCacheRatio: false,
imageRatio: false,
audioRatio: false,
audioCompletionRatio: false,
@@ -265,34 +376,82 @@ export const ModelRatioVisualEditor = memo(
billingExpr,
])
const handleEdit = useCallback((model: ModelRow) => {
setEditData({
name: model.name,
price: model.price,
ratio: model.ratio,
cacheRatio: model.cacheRatio,
createCacheRatio: model.createCacheRatio,
completionRatio: model.completionRatio,
imageRatio: model.imageRatio,
audioRatio: model.audioRatio,
audioCompletionRatio: model.audioCompletionRatio,
billingMode:
model.billingMode === 'tiered_expr'
? 'tiered_expr'
: model.price && model.price !== ''
? 'per-request'
: 'per-token',
billingExpr: model.billingExpr,
requestRuleExpr: model.requestRuleExpr,
})
setDialogOpen(true)
}, [])
const modeCounts = useMemo(
() =>
models.reduce(
(acc, model) => {
const mode =
model.billingMode === 'per-request' ||
model.billingMode === 'tiered_expr'
? model.billingMode
: 'per-token'
acc[mode] += 1
return acc
},
{
'per-token': 0,
'per-request': 0,
tiered_expr: 0,
} as Record<'per-token' | 'per-request' | 'tiered_expr', number>
),
[models]
)
const handleEdit = useCallback(
(model: ModelRow) => {
setEditData({
name: model.name,
price: model.price,
ratio: model.ratio,
cacheRatio: model.cacheRatio,
createCacheRatio: model.createCacheRatio,
completionRatio: model.completionRatio,
imageRatio: model.imageRatio,
audioRatio: model.audioRatio,
audioCompletionRatio: model.audioCompletionRatio,
billingMode:
model.billingMode === 'tiered_expr'
? 'tiered_expr'
: model.price && model.price !== ''
? 'per-request'
: 'per-token',
billingExpr: model.billingExpr,
requestRuleExpr: model.requestRuleExpr,
})
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
},
[isMobile]
)
const handleAdd = useCallback(() => {
setEditData(null)
setDialogOpen(true)
setEditorOpen(true)
if (isMobile) setSheetOpen(true)
}, [isMobile])
const handleCancel = useCallback(() => {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}, [])
const handleGlobalFilterChange = useCallback<OnChangeFn<string>>(
(updater) => {
setGlobalFilter((previous) => {
const next =
typeof updater === 'function' ? updater(previous) : updater
if (next !== previous) {
setEditData(null)
setEditorOpen(false)
setSheetOpen(false)
}
return next
})
},
[]
)
const handleDelete = useCallback(
(name: string) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
@@ -383,15 +542,32 @@ export const ModelRatioVisualEditor = memo(
)
const columns = useMemo<ColumnDef<ModelRow>[]>(() => {
// Ratio fields are not the primary pricing when a per-request fixed
// price is set, or when the model is in tiered_expr mode (the
// expression is primary; ratios are fallback during sync delays).
const isFallbackRow = (row: ModelRow) =>
row.billingMode === 'tiered_expr' || !!row.price
const fallbackClass = (row: ModelRow) =>
isFallbackRow(row) ? 'text-muted-foreground' : ''
return [
{
id: 'select',
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
indeterminate={table.getIsSomePageRowsSelected()}
onCheckedChange={(value) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label={t('Select all')}
className='translate-y-[2px]'
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label={t('Select row')}
className='translate-y-[2px]'
/>
),
enableSorting: false,
enableHiding: false,
meta: { label: t('Select') },
},
{
accessorKey: 'name',
header: ({ column }) => (
@@ -419,106 +595,41 @@ export const ModelRatioVisualEditor = memo(
enableHiding: false,
},
{
accessorKey: 'price',
accessorKey: 'billingMode',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Fixed price')} />
<DataTableColumnHeader column={column} title={t('Mode')} />
),
cell: ({ row }) => (
<span
className={
row.original.billingMode === 'tiered_expr'
? 'text-muted-foreground'
: ''
}
>
{formatValue(row.getValue('price'))}
</span>
<StatusBadge
label={t(getModeLabel(row.original.billingMode))}
variant={getModeVariant(row.original.billingMode)}
copyable={false}
/>
),
meta: { label: 'Fixed price' },
filterFn: (row, id, value) =>
filterBySelectedValues(row.getValue(id), value),
meta: { label: t('Mode') },
},
{
accessorKey: 'ratio',
id: 'priceSummary',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Ratio')} />
<DataTableColumnHeader column={column} title={t('Price summary')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('ratio'))}
</span>
<div className='flex min-w-[180px] flex-col gap-1'>
<span className='font-medium'>
{getPriceSummary(row.original, t)}
</span>
<span className='text-muted-foreground max-w-[320px] truncate text-xs'>
{getPriceDetail(row.original, t)}
</span>
</div>
),
meta: { label: 'Ratio' },
},
{
accessorKey: 'completionRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Completion')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('completionRatio'))}
</span>
),
meta: { label: 'Completion' },
},
{
accessorKey: 'cacheRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Cache')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('cacheRatio'))}
</span>
),
meta: { label: 'Cache' },
},
{
accessorKey: 'createCacheRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Create cache')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('createCacheRatio'))}
</span>
),
meta: { label: 'Create cache' },
},
{
accessorKey: 'imageRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Image')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('imageRatio'))}
</span>
),
meta: { label: 'Image' },
},
{
accessorKey: 'audioRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Audio')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('audioRatio'))}
</span>
),
meta: { label: 'Audio' },
},
{
accessorKey: 'audioCompletionRatio',
header: ({ column }) => (
<DataTableColumnHeader column={column} title={t('Audio comp.')} />
),
cell: ({ row }) => (
<span className={fallbackClass(row.original)}>
{formatValue(row.getValue('audioCompletionRatio'))}
</span>
),
meta: { label: 'Audio comp.' },
sortingFn: (rowA, rowB) =>
getPriceSummary(rowA.original, t).localeCompare(
getPriceSummary(rowB.original, t)
),
meta: { label: t('Price summary') },
},
{
id: 'actions',
@@ -529,14 +640,14 @@ export const ModelRatioVisualEditor = memo(
size='sm'
onClick={() => handleEdit(row.original)}
>
<Pencil className='h-4 w-4' />
<Pencil />
</Button>
<Button
variant='ghost'
size='sm'
onClick={() => handleDelete(row.original.name)}
>
<Trash2 className='h-4 w-4' />
<Trash2 />
</Button>
</div>
),
@@ -550,25 +661,34 @@ export const ModelRatioVisualEditor = memo(
columns,
state: {
sorting,
columnFilters,
globalFilter,
columnVisibility,
pagination,
rowSelection,
},
enableRowSelection: true,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onGlobalFilterChange: handleGlobalFilterChange,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
onRowSelectionChange: setRowSelection,
autoResetPageIndex: false,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
globalFilterFn: (row, _columnId, filterValue) => {
const searchValue = String(filterValue).toLowerCase()
return row.original.name.toLowerCase().includes(searchValue)
},
})
const handleSave = useCallback(
(data: ModelRatioData) => {
const persistPricingData = useCallback(
(data: ModelRatioData, targetNames: string[] = [data.name]) => {
const priceMap = safeJsonParse<Record<string, number>>(modelPrice, {
fallback: {},
silent: true,
@@ -610,58 +730,61 @@ export const ModelRatioVisualEditor = memo(
{ fallback: {}, silent: true }
)
delete priceMap[data.name]
delete ratioMap[data.name]
delete cacheMap[data.name]
delete createCacheMap[data.name]
delete completionMap[data.name]
delete imageMap[data.name]
delete audioMap[data.name]
delete audioCompletionMap[data.name]
delete billingModeMap[data.name]
delete billingExprMap[data.name]
const setIfPresent = (
target: Record<string, number>,
name: string,
value: string | undefined
) => {
if (!value || value === '') return
const parsed = parseFloat(value)
if (Number.isFinite(parsed)) target[data.name] = parsed
if (Number.isFinite(parsed)) target[name] = parsed
}
if (data.billingMode === 'tiered_expr') {
const combined = combineBillingExpr(
data.billingExpr || '',
data.requestRuleExpr || ''
)
if (combined) {
billingModeMap[data.name] = 'tiered_expr'
billingExprMap[data.name] = combined
targetNames.forEach((name) => {
delete priceMap[name]
delete ratioMap[name]
delete cacheMap[name]
delete createCacheMap[name]
delete completionMap[name]
delete imageMap[name]
delete audioMap[name]
delete audioCompletionMap[name]
delete billingModeMap[name]
delete billingExprMap[name]
if (data.billingMode === 'tiered_expr') {
const combined = combineBillingExpr(
data.billingExpr || '',
data.requestRuleExpr || ''
)
if (combined) {
billingModeMap[name] = 'tiered_expr'
billingExprMap[name] = combined
}
// Always serialize ratio/price values for tiered_expr models so they
// serve as fallback during multi-instance sync delays. The backend's
// ModelPriceHelper checks billing_mode first, so these values are
// only consulted when billing_setting hasn't propagated yet.
setIfPresent(priceMap, name, data.price)
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
} else if (data.price && data.price !== '') {
setIfPresent(priceMap, name, data.price)
} else {
setIfPresent(ratioMap, name, data.ratio)
setIfPresent(cacheMap, name, data.cacheRatio)
setIfPresent(createCacheMap, name, data.createCacheRatio)
setIfPresent(completionMap, name, data.completionRatio)
setIfPresent(imageMap, name, data.imageRatio)
setIfPresent(audioMap, name, data.audioRatio)
setIfPresent(audioCompletionMap, name, data.audioCompletionRatio)
}
// Always serialize ratio/price values for tiered_expr models so they
// serve as fallback during multi-instance sync delays. The backend's
// ModelPriceHelper checks billing_mode first, so these values are
// only consulted when billing_setting hasn't propagated yet.
setIfPresent(priceMap, data.price)
setIfPresent(ratioMap, data.ratio)
setIfPresent(cacheMap, data.cacheRatio)
setIfPresent(createCacheMap, data.createCacheRatio)
setIfPresent(completionMap, data.completionRatio)
setIfPresent(imageMap, data.imageRatio)
setIfPresent(audioMap, data.audioRatio)
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
} else if (data.price && data.price !== '') {
setIfPresent(priceMap, data.price)
} else {
setIfPresent(ratioMap, data.ratio)
setIfPresent(cacheMap, data.cacheRatio)
setIfPresent(createCacheMap, data.createCacheRatio)
setIfPresent(completionMap, data.completionRatio)
setIfPresent(imageMap, data.imageRatio)
setIfPresent(audioMap, data.audioRatio)
setIfPresent(audioCompletionMap, data.audioCompletionRatio)
}
})
onChange('ModelPrice', JSON.stringify(priceMap, null, 2))
onChange('ModelRatio', JSON.stringify(ratioMap, null, 2))
@@ -698,72 +821,191 @@ export const ModelRatioVisualEditor = memo(
]
)
const handleSave = useCallback(
(data: ModelRatioData) => {
persistPricingData(data)
setEditData(data)
setEditorOpen(true)
},
[persistPricingData]
)
const handleBatchCopy = useCallback(() => {
if (!editData) {
toast.error(t('Open a source model first'))
return
}
const targetNames = table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original.name)
if (targetNames.length === 0) {
toast.error(t('Select at least one target model'))
return
}
persistPricingData(editData, targetNames)
table.resetRowSelection()
toast.success(
t('Applied {{name}} pricing to {{count}} models', {
name: editData.name,
count: targetNames.length,
})
)
}, [editData, persistPricingData, t, table])
const selectedTargetCount = table.getFilteredSelectedRowModel().rows.length
return (
<div className='space-y-4'>
<div className='flex items-center justify-between gap-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Search models...')}
/>
<Button onClick={handleAdd}>
<Plus className='mr-2 h-4 w-4' />
{t('Add model')}
</Button>
<div className='flex flex-col gap-4'>
<div className='grid min-h-0 gap-4 md:grid-cols-[minmax(0,1fr)_minmax(420px,0.82fr)] xl:grid-cols-[minmax(0,1.1fr)_minmax(520px,0.9fr)]'>
<div className='flex min-w-0 flex-col gap-4'>
<DataTableToolbar
table={table}
searchPlaceholder={t('Search models...')}
filters={[
{
columnId: 'billingMode',
title: t('Mode'),
options: [
{
label: 'Per-token',
value: 'per-token',
count: modeCounts['per-token'],
},
{
label: 'Per-request',
value: 'per-request',
count: modeCounts['per-request'],
},
{
label: 'Expression',
value: 'tiered_expr',
count: modeCounts.tiered_expr,
},
],
},
]}
preActions={
<Button onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
}
/>
{table.getRowModel().rows.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{table.getState().globalFilter
? t('No models match your search')
: t('No models configured. Use Add model to get started.')}
</div>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() ? 'selected' : undefined
}
className={
editData?.name === row.original.name
? 'bg-muted/45'
: undefined
}
onClick={(event) => {
const target = event.target as HTMLElement
if (target.closest('button, [role="checkbox"]'))
return
handleEdit(row.original)
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
)}
</div>
<div className='hidden min-w-0 md:block'>
{editorOpen ? (
<ModelPricingEditorPanel
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
className='sticky top-4 h-[calc(100vh-8rem)] min-h-[620px]'
/>
) : (
<div className='bg-card text-muted-foreground sticky top-4 flex h-[calc(100vh-8rem)] min-h-[420px] flex-col items-center justify-center gap-3 rounded-xl border border-dashed p-6 text-center'>
<div className='text-foreground text-base font-medium'>
{t('Select a model to edit pricing')}
</div>
<p className='max-w-sm text-sm'>
{t(
'Use the full-width table to scan prices, then select a row to edit it here.'
)}
</p>
<Button variant='outline' onClick={handleAdd}>
<Plus data-icon='inline-start' />
{t('Add model')}
</Button>
</div>
)}
</div>
</div>
{table.getRowModel().rows.length === 0 ? (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
{table.getState().globalFilter
? t('No models match your search')
: t('No models configured. Click "Add model" to get started.')}
</div>
) : (
<div className='overflow-hidden rounded-md border'>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id} colSpan={header.colSpan}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow key={row.id}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
<DataTableBulkActions table={table} entityName={t('model')}>
<Button size='sm' disabled={!editData} onClick={handleBatchCopy}>
<Copy data-icon='inline-start' />
{editData
? t('Copy {{name}} pricing', { name: editData.name })
: t('Open a source model first')}
</Button>
</DataTableBulkActions>
{table.getRowModel().rows.length > 0 && (
<DataTablePagination table={table} />
{isMobile && (
<ModelPricingSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
onSave={handleSave}
onCancel={handleCancel}
editData={editData}
selectedTargetCount={selectedTargetCount}
/>
)}
<ModelRatioDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
onSave={handleSave}
editData={editData}
/>
</div>
)
},
@@ -179,17 +179,24 @@ const groupSchema = z.object({
type ModelFormValues = z.infer<typeof modelSchema>
type GroupFormValues = z.infer<typeof groupSchema>
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
type RatioSettingsCardProps = {
modelDefaults: ModelFormValues
groupDefaults: GroupFormValues
toolPricesDefault: string
titleKey?: string
descriptionKey?: string
visibleTabs?: RatioTabId[]
}
export function RatioSettingsCard({
modelDefaults,
groupDefaults,
toolPricesDefault,
titleKey = 'Pricing Ratios',
descriptionKey = 'Configure model, caching, and group ratios used for billing',
visibleTabs = ['models', 'groups', 'tool-prices', 'upstream-sync'],
}: RatioSettingsCardProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
@@ -414,61 +421,79 @@ export function RatioSettingsCard({
resetMutate()
}, [resetMutate])
const tabLabels: Record<RatioTabId, string> = {
models: 'Model ratios',
groups: 'Group ratios',
'tool-prices': 'Tool prices',
'upstream-sync': 'Upstream price sync',
}
const tabsGridClass =
{
1: 'grid-cols-1',
2: 'grid-cols-2',
3: 'grid-cols-3',
4: 'grid-cols-4',
}[visibleTabs.length] ?? 'grid-cols-4'
const defaultTab = visibleTabs[0] ?? 'models'
const renderTabContent = (tab: RatioTabId) => {
if (tab === 'models') {
return (
<ModelRatioForm
form={modelForm}
onSave={saveModelRatios}
onReset={handleResetRatios}
isSaving={updateOption.isPending}
isResetting={resetMutation.isPending}
/>
)
}
if (tab === 'groups') {
return (
<GroupRatioForm
form={groupForm}
onSave={saveGroupRatios}
isSaving={updateOption.isPending}
/>
)
}
if (tab === 'tool-prices') {
return <ToolPriceSettings defaultValue={toolPricesDefault} />
}
return (
<UpstreamRatioSync
modelRatios={{
ModelPrice: modelDefaults.ModelPrice,
ModelRatio: modelDefaults.ModelRatio,
CompletionRatio: modelDefaults.CompletionRatio,
CacheRatio: modelDefaults.CacheRatio,
CreateCacheRatio: modelDefaults.CreateCacheRatio,
ImageRatio: modelDefaults.ImageRatio,
AudioRatio: modelDefaults.AudioRatio,
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
'billing_setting.billing_mode': modelDefaults.BillingMode,
'billing_setting.billing_expr': modelDefaults.BillingExpr,
}}
/>
)
}
return (
<SettingsSection
title={t('Pricing Ratios')}
description={t(
'Configure model, caching, and group ratios used for billing'
)}
>
<Tabs defaultValue='models' className='space-y-6'>
<TabsList className='grid w-full grid-cols-4'>
<TabsTrigger value='models'>{t('Model ratios')}</TabsTrigger>
<TabsTrigger value='groups'>{t('Group ratios')}</TabsTrigger>
<TabsTrigger value='tool-prices'>{t('Tool prices')}</TabsTrigger>
<TabsTrigger value='upstream-sync'>
{t('Upstream price sync')}
</TabsTrigger>
<SettingsSection title={t(titleKey)} description={t(descriptionKey)}>
<Tabs defaultValue={defaultTab} className='space-y-6'>
<TabsList className={`grid w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
<TabsContent value='models'>
<ModelRatioForm
form={modelForm}
onSave={saveModelRatios}
onReset={handleResetRatios}
isSaving={updateOption.isPending}
isResetting={resetMutation.isPending}
/>
</TabsContent>
<TabsContent value='groups'>
<GroupRatioForm
form={groupForm}
onSave={saveGroupRatios}
isSaving={updateOption.isPending}
/>
</TabsContent>
<TabsContent value='tool-prices'>
<ToolPriceSettings defaultValue={toolPricesDefault} />
</TabsContent>
<TabsContent value='upstream-sync'>
<UpstreamRatioSync
modelRatios={{
ModelPrice: modelDefaults.ModelPrice,
ModelRatio: modelDefaults.ModelRatio,
CompletionRatio: modelDefaults.CompletionRatio,
CacheRatio: modelDefaults.CacheRatio,
CreateCacheRatio: modelDefaults.CreateCacheRatio,
ImageRatio: modelDefaults.ImageRatio,
AudioRatio: modelDefaults.AudioRatio,
AudioCompletionRatio: modelDefaults.AudioCompletionRatio,
'billing_setting.billing_mode': modelDefaults.BillingMode,
'billing_setting.billing_expr': modelDefaults.BillingExpr,
}}
/>
</TabsContent>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</Tabs>
<ConfirmDialog
@@ -4,7 +4,8 @@ import { ClaudeSettingsCard } from './claude-settings-card'
import { GeminiSettingsCard } from './gemini-settings-card'
import { GlobalSettingsCard } from './global-settings-card'
import { GrokSettingsCard } from './grok-settings-card'
import { RatioSettingsCard } from './ratio-settings-card'
import { ChannelAffinitySection } from '../general/channel-affinity'
import { IoNetDeploymentSettingsSection } from '../integrations/ionet-deployment-settings-section'
function formatJsonForEditor(value: string, fallback: string) {
const raw = (value ?? '').toString().trim()
@@ -106,34 +107,35 @@ const MODELS_SECTIONS = [
),
},
{
id: 'ratio',
titleKey: 'Pricing Ratios',
descriptionKey: 'Configure model pricing and ratio settings',
id: 'channel-affinity',
titleKey: 'Channel Affinity',
descriptionKey: 'Configure channel affinity (sticky routing) rules',
build: (settings: ModelSettings) => (
<RatioSettingsCard
modelDefaults={{
ModelPrice: settings.ModelPrice,
ModelRatio: settings.ModelRatio,
CacheRatio: settings.CacheRatio,
CreateCacheRatio: settings.CreateCacheRatio,
CompletionRatio: settings.CompletionRatio,
ImageRatio: settings.ImageRatio,
AudioRatio: settings.AudioRatio,
AudioCompletionRatio: settings.AudioCompletionRatio,
ExposeRatioEnabled: settings.ExposeRatioEnabled,
BillingMode: settings['billing_setting.billing_mode'],
BillingExpr: settings['billing_setting.billing_expr'],
<ChannelAffinitySection
defaultValues={{
'channel_affinity_setting.enabled':
settings['channel_affinity_setting.enabled'],
'channel_affinity_setting.switch_on_success':
settings['channel_affinity_setting.switch_on_success'],
'channel_affinity_setting.max_entries':
settings['channel_affinity_setting.max_entries'],
'channel_affinity_setting.default_ttl_seconds':
settings['channel_affinity_setting.default_ttl_seconds'],
'channel_affinity_setting.rules':
settings['channel_affinity_setting.rules'],
}}
toolPricesDefault={settings['tool_price_setting.prices']}
groupDefaults={{
TopupGroupRatio: settings.TopupGroupRatio,
GroupRatio: settings.GroupRatio,
UserUsableGroups: settings.UserUsableGroups,
GroupGroupRatio: settings.GroupGroupRatio,
AutoGroups: settings.AutoGroups,
DefaultUseAutoGroup: settings.DefaultUseAutoGroup,
GroupSpecialUsableGroup:
settings['group_ratio_setting.group_special_usable_group'],
/>
),
},
{
id: 'model-deployment',
titleKey: 'Model Deployment',
descriptionKey: 'Configure model deployment provider settings',
build: (settings: ModelSettings) => (
<IoNetDeploymentSettingsSection
defaultValues={{
enabled: settings['model_deployment.ionet.enabled'],
apiKey: settings['model_deployment.ionet.api_key'],
}}
/>
),
@@ -10,7 +10,7 @@ import {
type InputHTMLAttributes,
type MouseEvent as ReactMouseEvent,
} from 'react'
import { Copy, Plus, Trash2 } from 'lucide-react'
import { ChevronDown, Copy, Plus, Trash2 } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
@@ -31,6 +31,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import {
BILLING_EXTRA_VARS,
@@ -38,6 +39,10 @@ import {
MATCH_CONTAINS,
MATCH_EQ,
MATCH_EXISTS,
MATCH_GT,
MATCH_GTE,
MATCH_LT,
MATCH_LTE,
MATCH_RANGE,
SOURCE_HEADER,
SOURCE_PARAM,
@@ -48,7 +53,6 @@ import {
createEmptyCondition,
createEmptyRuleGroup,
createEmptyTimeCondition,
createEmptyTimeRuleGroup,
getRequestRuleMatchOptions,
splitBillingExprAndRequestRules,
tryParseRequestRuleExpr,
@@ -77,11 +81,20 @@ import {
} 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 VAR_OPTIONS: { value: TierConditionInput['var']; label: string }[] = [
{ value: 'len', label: 'len (input length)' },
{ value: 'p', label: 'p (input)' },
{ value: 'c', label: 'c (output)' },
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'][] = ['<', '<=', '>', '>=']
@@ -394,6 +407,11 @@ type ConditionRowProps = {
}
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
@@ -403,12 +421,16 @@ function ConditionRow({ condition, onChange, onRemove }: ConditionRowProps) {
}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue />
<SelectValue>
{currentInputOption
? t(currentInputOption.labelKey)
: condition.var}
</SelectValue>
</SelectTrigger>
<SelectContent>
{VAR_OPTIONS.map((option) => (
{CONDITION_INPUT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
{t(option.labelKey)}
</SelectItem>
))}
</SelectContent>
@@ -462,31 +484,19 @@ type PriceFieldProps = {
hint?: string
value: number
onChange: (next: number) => void
showSuffix?: boolean
}
function PriceField({
label,
hint,
value,
onChange,
showSuffix = true,
}: PriceFieldProps) {
function PriceField({ label, hint, value, onChange }: PriceFieldProps) {
return (
<div className='space-y-1'>
<Label className='text-xs'>{label}</Label>
<div className='flex items-center gap-2'>
<DraftNumberInput
min={0}
step={0.01}
value={Number.isFinite(value) ? value : 0}
onValueChange={onChange}
className='w-32'
/>
{showSuffix && (
<span className='text-muted-foreground text-xs'>{PRICE_SUFFIX}</span>
)}
</div>
<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>
)
@@ -547,21 +557,49 @@ function VisualTierCard({
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='bg-muted/30 space-y-3 rounded-md border p-3'>
<div className='flex items-center justify-between gap-2'>
<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-8 w-40'
className='h-7 w-36'
/>
</div>
<Button
@@ -575,9 +613,10 @@ function VisualTierCard({
</Button>
</div>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label className='text-xs'>{t('Conditions (AND)')}</Label>
{/* 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'
@@ -605,67 +644,90 @@ function VisualTierCard({
)}
</div>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
<PriceField
label={t('Input price')}
hint={`${inputUnitPrice} × p`}
value={inputUnitPrice}
onChange={(value) =>
handlePriceChange('input_unit_cost', priceToUnitCost(value))
}
/>
<PriceField
label={t('Output price')}
hint={`${outputUnitPrice} × c`}
value={outputUnitPrice}
onChange={(value) =>
handlePriceChange('output_unit_cost', priceToUnitCost(value))
}
/>
<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>
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<Label className='text-xs'>{t('Cache mode')}</Label>
<Select
value={cacheMode}
onValueChange={(value) => handleCacheModeChange(value as CacheMode)}
>
<SelectTrigger className='w-44' size='sm'>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={CACHE_MODE_GENERIC}>
{t('Generic cache')}
</SelectItem>
<SelectItem value={CACHE_MODE_TIMED}>
{t('Timed cache (1h)')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='grid grid-cols-1 gap-3 md:grid-cols-2'>
{BILLING_EXTRA_VARS.map((variable) => {
if (variable.key === 'cc1h' && cacheMode !== CACHE_MODE_TIMED) {
return null
}
const fieldKey = variable.tierField as keyof VisualTier
const value = unitCostToPrice(
(tier[fieldKey] as number | undefined) ?? 0
)
return (
<PriceField
key={variable.key}
label={variable.label}
hint={`${value} × ${variable.key}`}
value={value}
onChange={(next) =>
handlePriceChange(fieldKey, priceToUnitCost(next))
}
/>
)
})}
</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>
)
@@ -747,14 +809,12 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
}
return (
<div className='space-y-3'>
<Alert>
<AlertDescription className='text-xs'>
{t(
'Each tier supports 0~2 conditions (over len, p, c); the last tier is the catch-all without conditions. Use len (full input length, including cache hits) for tier conditions to avoid mis-routing when cache hits reduce p.'
)}
</AlertDescription>
</Alert>
<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}
@@ -766,7 +826,12 @@ function VisualEditor({ visualConfig, onChange }: VisualEditorProps) {
onAddCondition={() => handleAddCondition(index)}
/>
))}
<Button variant='outline' size='sm' onClick={handleAddTier}>
<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>
@@ -832,6 +897,50 @@ function RuleConditionRow({
}: 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) {
@@ -857,12 +966,12 @@ function RuleConditionRow({
}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue />
<SelectValue>{getTimeFuncLabel(timeCond.timeFunc)}</SelectValue>
</SelectTrigger>
<SelectContent>
{TIME_FUNCS.map((fn) => (
<SelectItem key={fn} value={fn}>
{fn}
{getTimeFuncLabel(fn)}
</SelectItem>
))}
</SelectContent>
@@ -874,7 +983,10 @@ function RuleConditionRow({
}
>
<SelectTrigger className='w-56' size='sm'>
<SelectValue />
<SelectValue>
{COMMON_TIMEZONES.find((tz) => tz.value === timeCond.timezone)
?.label ?? timeCond.timezone}
</SelectValue>
</SelectTrigger>
<SelectContent>
{COMMON_TIMEZONES.map((tz) => (
@@ -889,12 +1001,12 @@ function RuleConditionRow({
onValueChange={(v) => v !== null && handleModeChange(v)}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue />
<SelectValue>{getMatchLabel(timeCond.mode)}</SelectValue>
</SelectTrigger>
<SelectContent>
{matchOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
{getMatchLabel(option.value)}
</SelectItem>
))}
</SelectContent>
@@ -906,7 +1018,7 @@ function RuleConditionRow({
onValueChange={(value) =>
onChange({ ...timeCond, rangeStart: String(value) })
}
placeholder='start'
placeholder={t('Start')}
className='w-20'
/>
<span className='text-muted-foreground text-xs'>~</span>
@@ -915,7 +1027,7 @@ function RuleConditionRow({
onValueChange={(value) =>
onChange({ ...timeCond, rangeEnd: String(value) })
}
placeholder='end'
placeholder={t('End')}
className='w-20'
/>
</>
@@ -925,7 +1037,7 @@ function RuleConditionRow({
onValueChange={(value) =>
onChange({ ...timeCond, value: String(value) })
}
placeholder='value'
placeholder={t('Value')}
className='w-24'
/>
)}
@@ -947,12 +1059,12 @@ function RuleConditionRow({
onValueChange={(v) => v !== null && handleModeChange(v)}
>
<SelectTrigger className='w-32' size='sm'>
<SelectValue />
<SelectValue>{getMatchLabel(phCond.mode)}</SelectValue>
</SelectTrigger>
<SelectContent>
{matchOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(option.labelKey)}
{getMatchLabel(option.value)}
</SelectItem>
))}
</SelectContent>
@@ -977,7 +1089,7 @@ function RuleConditionRow({
onValueChange={(v) => v !== null && handleSourceChange(v)}
>
<SelectTrigger className='w-28' size='sm'>
<SelectValue />
<SelectValue>{sourceLabel}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={SOURCE_PARAM}>{t('Body param')}</SelectItem>
@@ -1214,7 +1326,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
</div>
<div className='grid grid-cols-2 gap-3'>
<div className='space-y-1'>
<Label className='text-xs'>{t('Input tokens')} (p)</Label>
<Label className='text-xs'>{t('Input tokens')}</Label>
<DraftNumberInput
min={0}
value={promptTokens}
@@ -1222,7 +1334,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
/>
</div>
<div className='space-y-1'>
<Label className='text-xs'>{t('Output tokens')} (c)</Label>
<Label className='text-xs'>{t('Output tokens')}</Label>
<DraftNumberInput
min={0}
value={completionTokens}
@@ -1243,9 +1355,7 @@ function CostEstimator({ effectiveExpr }: EstimatorProps) {
) as keyof ExtraTokenValues
return (
<div key={variable.key} className='space-y-1'>
<Label className='text-xs'>
{variable.shortLabel} ({variable.key})
</Label>
<Label className='text-xs'>{t(variable.shortLabel)}</Label>
<DraftNumberInput
min={0}
value={extras[stateKey]}
@@ -1612,7 +1722,7 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
<div className='flex-1'>
<PresetSection applyPreset={applyPreset} />
</div>
<LlmPromptHelper modelName={modelName} />
{editorMode === 'raw' && <LlmPromptHelper modelName={modelName} />}
</div>
<div className='bg-muted/30 space-y-3 rounded-md border p-3'>
@@ -1665,34 +1775,20 @@ export const TieredPricingEditor = memo(function TieredPricingEditor({
}
/>
))}
<div className='flex flex-wrap gap-2'>
<Button
variant='outline'
size='sm'
onClick={() =>
handleRuleGroupsChange([
...requestRuleGroups,
createEmptyRuleGroup(),
])
}
>
<Plus className='mr-2 h-4 w-4' />
{t('Add rule group')}
</Button>
<Button
variant='ghost'
size='sm'
onClick={() =>
handleRuleGroupsChange([
...requestRuleGroups,
createEmptyTimeRuleGroup(),
])
}
>
<Plus className='mr-2 h-4 w-4' />
{t('Add time rule group')}
</Button>
</div>
<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>
@@ -0,0 +1,95 @@
import { useMemo } from 'react'
import { useParams } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { useStatus } from '@/hooks/use-status'
import { getOptionValue, useSystemOptions } from '../hooks/use-system-options'
import type { OperationsSettings } from '../types'
import {
OPERATIONS_DEFAULT_SECTION,
getOperationsSectionContent,
} from './section-registry.tsx'
const defaultOperationsSettings: OperationsSettings = {
RetryTimes: 0,
DefaultCollapseSidebar: false,
DemoSiteEnabled: false,
SelfUseModeEnabled: false,
ChannelDisableThreshold: '',
QuotaRemindThreshold: '',
AutomaticDisableChannelEnabled: false,
AutomaticEnableChannelEnabled: false,
AutomaticDisableKeywords: '',
AutomaticDisableStatusCodes: '401',
AutomaticRetryStatusCodes:
'100-199,300-399,401-407,409-499,500-503,505-523,525-599',
'monitor_setting.auto_test_channel_enabled': false,
'monitor_setting.auto_test_channel_minutes': 10,
SMTPServer: '',
SMTPPort: '',
SMTPAccount: '',
SMTPFrom: '',
SMTPToken: '',
SMTPSSLEnabled: false,
SMTPForceAuthLogin: false,
WorkerUrl: '',
WorkerValidKey: '',
WorkerAllowHttpImageRequestEnabled: false,
LogConsumeEnabled: false,
'performance_setting.disk_cache_enabled': false,
'performance_setting.disk_cache_threshold_mb': 10,
'performance_setting.disk_cache_max_size_mb': 1024,
'performance_setting.disk_cache_path': '',
'performance_setting.monitor_enabled': false,
'performance_setting.monitor_cpu_threshold': 90,
'performance_setting.monitor_memory_threshold': 90,
'performance_setting.monitor_disk_threshold': 95,
'perf_metrics_setting.enabled': true,
'perf_metrics_setting.flush_interval': 5,
'perf_metrics_setting.bucket_time': 'hour',
'perf_metrics_setting.retention_days': 0,
}
export function OperationsSettings() {
const { t } = useTranslation()
const { data, isLoading } = useSystemOptions()
const { status } = useStatus()
const params = useParams({
from: '/_authenticated/system-settings/operations/$section',
})
const settings = useMemo(
() => getOptionValue(data?.data, defaultOperationsSettings),
[data?.data]
)
if (isLoading) {
return (
<div className='text-muted-foreground flex h-full w-full flex-1 items-center justify-center'>
{t('Loading maintenance settings...')}
</div>
)
}
const activeSection = (params?.section ?? OPERATIONS_DEFAULT_SECTION) as
| 'behavior'
| 'monitoring'
| 'email'
| 'worker'
| 'logs'
| 'performance'
| 'update-checker'
const sectionContent = getOperationsSectionContent(
activeSection,
settings,
status?.version as string | undefined,
status?.start_time as number | null | undefined
)
return (
<div className='flex h-full w-full flex-1 flex-col'>
<div className='faded-bottom h-full w-full overflow-y-auto scroll-smooth pe-4 pb-12'>
<div className='space-y-4'>{sectionContent}</div>
</div>
</div>
)
}
@@ -0,0 +1,163 @@
import type { OperationsSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { SystemBehaviorSection } from '../general/system-behavior-section'
import { EmailSettingsSection } from '../integrations/email-settings-section'
import { MonitoringSettingsSection } from '../integrations/monitoring-settings-section'
import { WorkerSettingsSection } from '../integrations/worker-settings-section'
import { LogSettingsSection } from '../maintenance/log-settings-section'
import { PerformanceSection } from '../maintenance/performance-section'
import { UpdateCheckerSection } from '../maintenance/update-checker-section'
const OPERATIONS_SECTIONS = [
{
id: 'behavior',
titleKey: 'System Behavior',
descriptionKey: 'Configure system-wide behavior and defaults',
build: (settings: OperationsSettings) => (
<SystemBehaviorSection
defaultValues={{
RetryTimes: settings.RetryTimes,
DefaultCollapseSidebar: settings.DefaultCollapseSidebar,
DemoSiteEnabled: settings.DemoSiteEnabled,
SelfUseModeEnabled: settings.SelfUseModeEnabled,
}}
/>
),
},
{
id: 'monitoring',
titleKey: 'Monitoring & Alerts',
descriptionKey: 'Configure channel monitoring and automation',
build: (settings: OperationsSettings) => (
<MonitoringSettingsSection
defaultValues={{
ChannelDisableThreshold: settings.ChannelDisableThreshold,
QuotaRemindThreshold: settings.QuotaRemindThreshold,
AutomaticDisableChannelEnabled:
settings.AutomaticDisableChannelEnabled,
AutomaticEnableChannelEnabled: settings.AutomaticEnableChannelEnabled,
AutomaticDisableKeywords: settings.AutomaticDisableKeywords,
AutomaticDisableStatusCodes: settings.AutomaticDisableStatusCodes,
AutomaticRetryStatusCodes: settings.AutomaticRetryStatusCodes,
'monitor_setting.auto_test_channel_enabled':
settings['monitor_setting.auto_test_channel_enabled'],
'monitor_setting.auto_test_channel_minutes':
settings['monitor_setting.auto_test_channel_minutes'],
}}
/>
),
},
{
id: 'email',
titleKey: 'SMTP Email',
descriptionKey: 'Configure SMTP email settings',
build: (settings: OperationsSettings) => (
<EmailSettingsSection
defaultValues={{
SMTPServer: settings.SMTPServer,
SMTPPort: settings.SMTPPort,
SMTPAccount: settings.SMTPAccount,
SMTPFrom: settings.SMTPFrom,
SMTPToken: settings.SMTPToken,
SMTPSSLEnabled: settings.SMTPSSLEnabled,
SMTPForceAuthLogin: settings.SMTPForceAuthLogin,
}}
/>
),
},
{
id: 'worker',
titleKey: 'Worker Proxy',
descriptionKey: 'Configure worker service settings',
build: (settings: OperationsSettings) => (
<WorkerSettingsSection
defaultValues={{
WorkerUrl: settings.WorkerUrl,
WorkerValidKey: settings.WorkerValidKey,
WorkerAllowHttpImageRequestEnabled:
settings.WorkerAllowHttpImageRequestEnabled,
}}
/>
),
},
{
id: 'logs',
titleKey: 'Log Maintenance',
descriptionKey: 'Configure log consumption settings',
build: (settings: OperationsSettings) => (
<LogSettingsSection
defaultEnabled={Boolean(settings.LogConsumeEnabled)}
/>
),
},
{
id: 'performance',
titleKey: 'Performance',
descriptionKey: 'Disk cache, system monitoring and performance stats',
build: (settings: OperationsSettings) => (
<PerformanceSection
defaultValues={{
'performance_setting.disk_cache_enabled':
settings['performance_setting.disk_cache_enabled'] ?? false,
'performance_setting.disk_cache_threshold_mb':
settings['performance_setting.disk_cache_threshold_mb'] ?? 10,
'performance_setting.disk_cache_max_size_mb':
settings['performance_setting.disk_cache_max_size_mb'] ?? 1024,
'performance_setting.disk_cache_path':
settings['performance_setting.disk_cache_path'] ?? '',
'performance_setting.monitor_enabled':
settings['performance_setting.monitor_enabled'] ?? false,
'performance_setting.monitor_cpu_threshold':
settings['performance_setting.monitor_cpu_threshold'] ?? 90,
'performance_setting.monitor_memory_threshold':
settings['performance_setting.monitor_memory_threshold'] ?? 90,
'performance_setting.monitor_disk_threshold':
settings['performance_setting.monitor_disk_threshold'] ?? 95,
'perf_metrics_setting.enabled':
settings['perf_metrics_setting.enabled'] ?? true,
'perf_metrics_setting.flush_interval':
settings['perf_metrics_setting.flush_interval'] ?? 5,
'perf_metrics_setting.bucket_time':
settings['perf_metrics_setting.bucket_time'] ?? 'hour',
'perf_metrics_setting.retention_days':
settings['perf_metrics_setting.retention_days'] ?? 0,
}}
/>
),
},
{
id: 'update-checker',
titleKey: 'System maintenance',
descriptionKey: 'Check for system updates',
build: (
_settings: OperationsSettings,
currentVersion?: string | null,
startTime?: number | null
) => (
<UpdateCheckerSection
currentVersion={currentVersion}
startTime={startTime}
/>
),
},
] as const
export type OperationsSectionId = (typeof OPERATIONS_SECTIONS)[number]['id']
const operationsRegistry = createSectionRegistry<
OperationsSectionId,
OperationsSettings,
[string | null | undefined, number | null | undefined]
>({
sections: OPERATIONS_SECTIONS,
defaultSection: 'behavior',
basePath: '/system-settings/operations',
urlStyle: 'path',
})
export const OPERATIONS_SECTION_IDS = operationsRegistry.sectionIds
export const OPERATIONS_DEFAULT_SECTION = operationsRegistry.defaultSection
export const getOperationsSectionNavItems =
operationsRegistry.getSectionNavItems
export const getOperationsSectionContent =
operationsRegistry.getSectionContent
@@ -1,11 +1,11 @@
import { SettingsPage } from '../components/settings-page'
import type { RequestLimitsSettings } from '../types'
import type { SecuritySettings } from '../types'
import {
REQUEST_LIMITS_DEFAULT_SECTION,
getRequestLimitsSectionContent,
SECURITY_DEFAULT_SECTION,
getSecuritySectionContent,
} from './section-registry.tsx'
const defaultRequestLimitsSettings: RequestLimitsSettings = {
const defaultSecuritySettings: SecuritySettings = {
ModelRequestRateLimitEnabled: false,
ModelRequestRateLimitCount: 0,
ModelRequestRateLimitSuccessCount: 1000,
@@ -24,13 +24,13 @@ const defaultRequestLimitsSettings: RequestLimitsSettings = {
'fetch_setting.apply_ip_filter_for_domain': false,
}
export function RequestLimitsSettings() {
export function SecuritySettings() {
return (
<SettingsPage
routePath='/_authenticated/system-settings/request-limits/$section'
defaultSettings={defaultRequestLimitsSettings}
defaultSection={REQUEST_LIMITS_DEFAULT_SECTION}
getSectionContent={getRequestLimitsSectionContent}
routePath='/_authenticated/system-settings/security/$section'
defaultSettings={defaultSecuritySettings}
defaultSection={SECURITY_DEFAULT_SECTION}
getSectionContent={getSecuritySectionContent}
/>
)
}
@@ -1,15 +1,15 @@
import type { RequestLimitsSettings } from '../types'
import type { SecuritySettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import { RateLimitSection } from './rate-limit-section'
import { SensitiveWordsSection } from './sensitive-words-section'
import { SSRFSection } from './ssrf-section'
import { RateLimitSection } from '../request-limits/rate-limit-section'
import { SensitiveWordsSection } from '../request-limits/sensitive-words-section'
import { SSRFSection } from '../request-limits/ssrf-section'
const REQUEST_LIMITS_SECTIONS = [
const SECURITY_SECTIONS = [
{
id: 'rate-limit',
titleKey: 'Rate Limiting',
descriptionKey: 'Configure model request rate limiting',
build: (settings: RequestLimitsSettings) => (
build: (settings: SecuritySettings) => (
<RateLimitSection
defaultValues={{
ModelRequestRateLimitEnabled: settings.ModelRequestRateLimitEnabled,
@@ -27,7 +27,7 @@ const REQUEST_LIMITS_SECTIONS = [
id: 'sensitive-words',
titleKey: 'Sensitive Words',
descriptionKey: 'Configure sensitive word filtering',
build: (settings: RequestLimitsSettings) => (
build: (settings: SecuritySettings) => (
<SensitiveWordsSection
defaultValues={{
CheckSensitiveEnabled: settings.CheckSensitiveEnabled,
@@ -41,7 +41,7 @@ const REQUEST_LIMITS_SECTIONS = [
id: 'ssrf',
titleKey: 'SSRF Protection',
descriptionKey: 'Configure SSRF (Server-Side Request Forgery) protection',
build: (settings: RequestLimitsSettings) => (
build: (settings: SecuritySettings) => (
<SSRFSection
defaultValues={{
'fetch_setting.enable_ssrf_protection':
@@ -64,23 +64,19 @@ const REQUEST_LIMITS_SECTIONS = [
},
] as const
export type RequestLimitsSectionId =
(typeof REQUEST_LIMITS_SECTIONS)[number]['id']
export type SecuritySectionId = (typeof SECURITY_SECTIONS)[number]['id']
const requestLimitsRegistry = createSectionRegistry<
RequestLimitsSectionId,
RequestLimitsSettings
const securityRegistry = createSectionRegistry<
SecuritySectionId,
SecuritySettings
>({
sections: REQUEST_LIMITS_SECTIONS,
sections: SECURITY_SECTIONS,
defaultSection: 'rate-limit',
basePath: '/system-settings/request-limits',
basePath: '/system-settings/security',
urlStyle: 'path',
})
export const REQUEST_LIMITS_SECTION_IDS = requestLimitsRegistry.sectionIds
export const REQUEST_LIMITS_DEFAULT_SECTION =
requestLimitsRegistry.defaultSection
export const getRequestLimitsSectionNavItems =
requestLimitsRegistry.getSectionNavItems
export const getRequestLimitsSectionContent =
requestLimitsRegistry.getSectionContent
export const SECURITY_SECTION_IDS = securityRegistry.sectionIds
export const SECURITY_DEFAULT_SECTION = securityRegistry.defaultSection
export const getSecuritySectionNavItems = securityRegistry.getSectionNavItems
export const getSecuritySectionContent = securityRegistry.getSectionContent
+32
View File
@@ -0,0 +1,32 @@
import { SettingsPage } from '../components/settings-page'
import type { SiteSettings } from '../types'
import {
SITE_DEFAULT_SECTION,
getSiteSectionContent,
} from './section-registry.tsx'
const defaultSiteSettings: SiteSettings = {
'theme.frontend': 'default',
Notice: '',
SystemName: 'New API',
Logo: '',
Footer: '',
About: '',
HomePageContent: '',
ServerAddress: '',
'legal.user_agreement': '',
'legal.privacy_policy': '',
HeaderNavModules: '',
SidebarModulesAdmin: '',
}
export function SiteSettings() {
return (
<SettingsPage
routePath='/_authenticated/system-settings/site/$section'
defaultSettings={defaultSiteSettings}
defaultSection={SITE_DEFAULT_SECTION}
getSectionContent={getSiteSectionContent}
/>
)
}
@@ -0,0 +1,93 @@
import type { SiteSettings } from '../types'
import { createSectionRegistry } from '../utils/section-registry'
import {
parseHeaderNavModules,
parseSidebarModulesAdmin,
serializeHeaderNavModules,
serializeSidebarModulesAdmin,
} from '../maintenance/config'
import { HeaderNavigationSection } from '../maintenance/header-navigation-section'
import { NoticeSection } from '../maintenance/notice-section'
import { SidebarModulesSection } from '../maintenance/sidebar-modules-section'
import { SystemInfoSection } from '../general/system-info-section'
const SITE_SECTIONS = [
{
id: 'system-info',
titleKey: 'System Information',
descriptionKey: 'Configure basic system information and branding',
build: (settings: SiteSettings) => (
<SystemInfoSection
defaultValues={{
theme: {
frontend: settings['theme.frontend'] as 'default' | 'classic',
},
SystemName: settings.SystemName,
Logo: settings.Logo,
Footer: settings.Footer,
About: settings.About,
HomePageContent: settings.HomePageContent,
ServerAddress: settings.ServerAddress,
legal: {
user_agreement: settings['legal.user_agreement'],
privacy_policy: settings['legal.privacy_policy'],
},
}}
/>
),
},
{
id: 'notice',
titleKey: 'System Notice',
descriptionKey: 'Configure system maintenance notice',
build: (settings: SiteSettings) => (
<NoticeSection defaultValue={settings.Notice ?? ''} />
),
},
{
id: 'header-navigation',
titleKey: 'Header navigation',
descriptionKey: 'Configure header navigation modules',
build: (settings: SiteSettings) => {
const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
return (
<HeaderNavigationSection
config={headerNavConfig}
initialSerialized={headerNavSerialized}
/>
)
},
},
{
id: 'sidebar-modules',
titleKey: 'Sidebar modules',
descriptionKey: 'Configure sidebar modules for admin',
build: (settings: SiteSettings) => {
const sidebarConfig = parseSidebarModulesAdmin(
settings.SidebarModulesAdmin
)
const sidebarSerialized = serializeSidebarModulesAdmin(sidebarConfig)
return (
<SidebarModulesSection
config={sidebarConfig}
initialSerialized={sidebarSerialized}
/>
)
},
},
] as const
export type SiteSectionId = (typeof SITE_SECTIONS)[number]['id']
const siteRegistry = createSectionRegistry<SiteSectionId, SiteSettings>({
sections: SITE_SECTIONS,
defaultSection: 'system-info',
basePath: '/system-settings/site',
urlStyle: 'path',
})
export const SITE_SECTION_IDS = siteRegistry.sectionIds
export const SITE_DEFAULT_SECTION = siteRegistry.defaultSection
export const getSiteSectionNavItems = siteRegistry.getSectionNavItems
export const getSiteSectionContent = siteRegistry.getSectionContent
+110 -92
View File
@@ -27,7 +27,7 @@ export type DeleteLogsResponse = {
data?: number
}
export type GeneralSettings = {
export type SiteSettings = {
'theme.frontend': string
Notice: string
SystemName: string
@@ -38,32 +38,8 @@ export type GeneralSettings = {
ServerAddress: string
'legal.user_agreement': string
'legal.privacy_policy': string
QuotaForNewUser: number
PreConsumedQuota: number
QuotaForInviter: number
QuotaForInvitee: number
TopUpLink: string
'general_setting.docs_link': string
'quota_setting.enable_free_model_pre_consume': boolean
QuotaPerUnit: number
USDExchangeRate: number
'general_setting.quota_display_type': string
'general_setting.custom_currency_symbol': string
'general_setting.custom_currency_exchange_rate': number
RetryTimes: number
DisplayInCurrencyEnabled: boolean
DisplayTokenStatEnabled: boolean
DefaultCollapseSidebar: boolean
DemoSiteEnabled: boolean
SelfUseModeEnabled: boolean
'checkin_setting.enabled': boolean
'checkin_setting.min_quota': number
'checkin_setting.max_quota': number
'channel_affinity_setting.enabled': boolean
'channel_affinity_setting.switch_on_success': boolean
'channel_affinity_setting.max_entries': number
'channel_affinity_setting.default_ttl_seconds': number
'channel_affinity_setting.rules': string
HeaderNavModules: string
SidebarModulesAdmin: string
}
export type AuthSettings = {
@@ -131,28 +107,87 @@ export type ContentSettings = {
MjActionCheckSuccessEnabled: boolean
}
export type IntegrationSettings = {
SMTPServer: string
SMTPPort: string
SMTPAccount: string
SMTPFrom: string
SMTPToken: string
SMTPSSLEnabled: boolean
SMTPForceAuthLogin: boolean
WorkerUrl: string
WorkerValidKey: string
WorkerAllowHttpImageRequestEnabled: boolean
ChannelDisableThreshold: string
QuotaRemindThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
export type ModelSettings = {
'global.pass_through_request_enabled': boolean
'global.thinking_model_blacklist': string
'global.chat_completions_to_responses_policy': string
'general_setting.ping_interval_enabled': boolean
'general_setting.ping_interval_seconds': number
'gemini.safety_settings': string
'gemini.version_settings': string
'gemini.supported_imagine_models': string
'gemini.thinking_adapter_enabled': boolean
'gemini.thinking_adapter_budget_tokens_percentage': number
'gemini.function_call_thought_signature_enabled': boolean
'gemini.remove_function_response_id_enabled': boolean
'claude.model_headers_settings': string
'claude.default_max_tokens': string
'claude.thinking_adapter_enabled': boolean
'claude.thinking_adapter_budget_tokens_percentage': number
'grok.violation_deduction_enabled': boolean
'grok.violation_deduction_amount': number
ModelPrice: string
ModelRatio: string
CacheRatio: string
CreateCacheRatio: string
CompletionRatio: string
ImageRatio: string
AudioRatio: string
AudioCompletionRatio: string
ExposeRatioEnabled: boolean
'billing_setting.billing_mode': string
'billing_setting.billing_expr': string
'tool_price_setting.prices': string
TopupGroupRatio: string
GroupRatio: string
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
'channel_affinity_setting.enabled': boolean
'channel_affinity_setting.switch_on_success': boolean
'channel_affinity_setting.max_entries': number
'channel_affinity_setting.default_ttl_seconds': number
'channel_affinity_setting.rules': string
'model_deployment.ionet.api_key': string
'model_deployment.ionet.enabled': boolean
}
export type BillingSettings = {
QuotaForNewUser: number
PreConsumedQuota: number
QuotaForInviter: number
QuotaForInvitee: number
TopUpLink: string
'general_setting.docs_link': string
'quota_setting.enable_free_model_pre_consume': boolean
QuotaPerUnit: number
USDExchangeRate: number
'general_setting.quota_display_type': string
'general_setting.custom_currency_symbol': string
'general_setting.custom_currency_exchange_rate': number
DisplayInCurrencyEnabled: boolean
DisplayTokenStatEnabled: boolean
ModelPrice: string
ModelRatio: string
CacheRatio: string
CreateCacheRatio: string
CompletionRatio: string
ImageRatio: string
AudioRatio: string
AudioCompletionRatio: string
ExposeRatioEnabled: boolean
'billing_setting.billing_mode': string
'billing_setting.billing_expr': string
'tool_price_setting.prices': string
TopupGroupRatio: string
GroupRatio: string
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
PayAddress: string
EpayId: string
EpayKey: string
@@ -199,53 +234,36 @@ export type IntegrationSettings = {
WaffoPancakeCurrency: string
WaffoPancakeUnitPrice: number
WaffoPancakeMinTopUp: number
'checkin_setting.enabled': boolean
'checkin_setting.min_quota': number
'checkin_setting.max_quota': number
}
export type ModelSettings = {
'global.pass_through_request_enabled': boolean
'global.thinking_model_blacklist': string
'global.chat_completions_to_responses_policy': string
'general_setting.ping_interval_enabled': boolean
'general_setting.ping_interval_seconds': number
'gemini.safety_settings': string
'gemini.version_settings': string
'gemini.supported_imagine_models': string
'gemini.thinking_adapter_enabled': boolean
'gemini.thinking_adapter_budget_tokens_percentage': number
'gemini.function_call_thought_signature_enabled': boolean
'gemini.remove_function_response_id_enabled': boolean
'claude.model_headers_settings': string
'claude.default_max_tokens': string
'claude.thinking_adapter_enabled': boolean
'claude.thinking_adapter_budget_tokens_percentage': number
'grok.violation_deduction_enabled': boolean
'grok.violation_deduction_amount': number
ModelPrice: string
ModelRatio: string
CacheRatio: string
CreateCacheRatio: string
CompletionRatio: string
ImageRatio: string
AudioRatio: string
AudioCompletionRatio: string
ExposeRatioEnabled: boolean
'billing_setting.billing_mode': string
'billing_setting.billing_expr': string
'tool_price_setting.prices': string
TopupGroupRatio: string
GroupRatio: string
UserUsableGroups: string
GroupGroupRatio: string
AutoGroups: string
DefaultUseAutoGroup: boolean
'group_ratio_setting.group_special_usable_group': string
}
export type MaintenanceSettings = {
Notice: string
export type OperationsSettings = {
RetryTimes: number
DefaultCollapseSidebar: boolean
DemoSiteEnabled: boolean
SelfUseModeEnabled: boolean
ChannelDisableThreshold: string
QuotaRemindThreshold: string
AutomaticDisableChannelEnabled: boolean
AutomaticEnableChannelEnabled: boolean
AutomaticDisableKeywords: string
AutomaticDisableStatusCodes: string
AutomaticRetryStatusCodes: string
'monitor_setting.auto_test_channel_enabled': boolean
'monitor_setting.auto_test_channel_minutes': number
SMTPServer: string
SMTPPort: string
SMTPAccount: string
SMTPFrom: string
SMTPToken: string
SMTPSSLEnabled: boolean
SMTPForceAuthLogin: boolean
WorkerUrl: string
WorkerValidKey: string
WorkerAllowHttpImageRequestEnabled: boolean
LogConsumeEnabled: boolean
HeaderNavModules: string
SidebarModulesAdmin: string
'performance_setting.disk_cache_enabled': boolean
'performance_setting.disk_cache_threshold_mb': number
'performance_setting.disk_cache_max_size_mb': number
@@ -260,7 +278,7 @@ export type MaintenanceSettings = {
'perf_metrics_setting.retention_days': number
}
export type RequestLimitsSettings = {
export type SecuritySettings = {
ModelRequestRateLimitEnabled: boolean
ModelRequestRateLimitCount: number
ModelRequestRateLimitSuccessCount: number
@@ -29,7 +29,7 @@ export type SettingsRouteConfigOptions<
defaultSection: TSectionId
/** Settings component to render */
component: TComponent
/** Route path for redirect (e.g., '/system-settings/general') */
/** Route path for redirect (e.g., '/system-settings/site') */
routePath: string
/** Whether to redirect to default section if no section is provided (default: false) */
redirectToDefault?: boolean
@@ -44,12 +44,12 @@ export type SettingsRouteConfigOptions<
*
* @example
* ```tsx
* export const Route = createFileRoute('/_authenticated/system-settings/general')(
* export const Route = createFileRoute('/_authenticated/system-settings/site')(
* createSettingsRouteConfig({
* sectionIds: GENERAL_SECTION_IDS,
* defaultSection: GENERAL_DEFAULT_SECTION,
* component: GeneralSettings,
* routePath: '/system-settings/general',
* sectionIds: SITE_SECTION_IDS,
* defaultSection: SITE_DEFAULT_SECTION,
* component: SiteSettings,
* routePath: '/system-settings/site',
* redirectToDefault: true,
* })
* )