+ {t('If this keeps happening, please report it on GitHub Issues.')}
+
+
+ }
+ >
+ {t('Report an issue')}
+
diff --git a/web/default/src/features/models/components/deployment-access-guard.tsx b/web/default/src/features/models/components/deployment-access-guard.tsx
index 726f5079..189c7e03 100644
--- a/web/default/src/features/models/components/deployment-access-guard.tsx
+++ b/web/default/src/features/models/components/deployment-access-guard.tsx
@@ -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
diff --git a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx
index 8ee6bc61..9028ba10 100644
--- a/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx
+++ b/web/default/src/features/models/components/drawers/model-mutate-drawer.tsx
@@ -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])
diff --git a/web/default/src/features/system-settings/billing/index.tsx b/web/default/src/features/system-settings/billing/index.tsx
new file mode 100644
index 00000000..f9f4b389
--- /dev/null
+++ b/web/default/src/features/system-settings/billing/index.tsx
@@ -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 (
+
+ )
+}
diff --git a/web/default/src/features/system-settings/billing/section-registry.tsx b/web/default/src/features/system-settings/billing/section-registry.tsx
new file mode 100644
index 00000000..54c7ea61
--- /dev/null
+++ b/web/default/src/features/system-settings/billing/section-registry.tsx
@@ -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) => (
+
+ ),
+ },
+ {
+ id: 'currency',
+ titleKey: 'Currency & Display',
+ descriptionKey: 'Configure currency conversion and quota display options',
+ build: (settings: BillingSettings) => (
+
+ ),
+ },
+ {
+ id: 'model-pricing',
+ titleKey: 'Model Pricing',
+ descriptionKey: 'Configure model pricing ratios and tool prices',
+ build: (settings: BillingSettings) => (
+
+ ),
+ },
+ {
+ id: 'group-pricing',
+ titleKey: 'Group Pricing',
+ descriptionKey: 'Configure group ratios and group-specific pricing rules',
+ build: (settings: BillingSettings) => (
+
+ ),
+ },
+ {
+ id: 'payment',
+ titleKey: 'Payment Gateway',
+ descriptionKey: 'Configure payment gateway integrations',
+ build: (settings: BillingSettings) => (
+
+ ),
+ },
+ {
+ id: 'checkin',
+ titleKey: 'Check-in Rewards',
+ descriptionKey: 'Configure daily check-in rewards for users',
+ build: (settings: BillingSettings) => (
+
+ ),
+ },
+] as const
+
+export type BillingSectionId = (typeof BILLING_SECTIONS)[number]['id']
+
+const billingRegistry = createSectionRegistry
(
+ {
+ 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
diff --git a/web/default/src/features/system-settings/general/index.tsx b/web/default/src/features/system-settings/general/index.tsx
deleted file mode 100644
index 054a3f86..00000000
--- a/web/default/src/features/system-settings/general/index.tsx
+++ /dev/null
@@ -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 (
-
-
{t('Loading settings...')}
-
- )
- }
-
- 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 (
-
- )
-}
diff --git a/web/default/src/features/system-settings/general/section-registry.tsx b/web/default/src/features/system-settings/general/section-registry.tsx
deleted file mode 100644
index f79c042a..00000000
--- a/web/default/src/features/system-settings/general/section-registry.tsx
+++ /dev/null
@@ -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) => (
-
- ),
- },
- {
- id: 'quota',
- titleKey: 'Quota Settings',
- descriptionKey: 'Configure user quota allocation and rewards',
- build: (settings: GeneralSettings) => (
-
- ),
- },
- {
- id: 'pricing',
- titleKey: 'Pricing & Display',
- descriptionKey: 'Configure pricing model and display options',
- build: (
- settings: GeneralSettings,
- quotaDisplayType: 'USD' | 'CNY' | 'TOKENS' | 'CUSTOM'
- ) => (
-
- ),
- },
- {
- id: 'checkin',
- titleKey: 'Check-in Settings',
- descriptionKey: 'Configure daily check-in rewards for users',
- build: (settings: GeneralSettings) => (
-
- ),
- },
- {
- id: 'behavior',
- titleKey: 'System Behavior',
- descriptionKey: 'Configure system-wide behavior and defaults',
- build: (settings: GeneralSettings) => (
-
- ),
- },
- {
- id: 'channel-affinity',
- titleKey: 'Channel Affinity',
- descriptionKey: 'Configure channel affinity (sticky routing) rules',
- build: (settings: GeneralSettings) => (
-
- ),
- },
-] 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
diff --git a/web/default/src/features/system-settings/general/system-info-section.tsx b/web/default/src/features/system-settings/general/system-info-section.tsx
index ff5064a7..255af00b 100644
--- a/web/default/src/features/system-settings/general/system-info-section.tsx
+++ b/web/default/src/features/system-settings/general/system-info-section.tsx
@@ -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) {
)}
/>
- (
-
- {t('Notice')}
-
-
-
-
- {t(
- 'Announcement displayed to users (supports Markdown & HTML)'
- )}
-
-
-
- )}
- />
-
- )
-}
diff --git a/web/default/src/features/system-settings/integrations/section-registry.tsx b/web/default/src/features/system-settings/integrations/section-registry.tsx
deleted file mode 100644
index ff3f0741..00000000
--- a/web/default/src/features/system-settings/integrations/section-registry.tsx
+++ /dev/null
@@ -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) => (
-
- ),
- },
- {
- id: 'email',
- titleKey: 'SMTP Email',
- descriptionKey: 'Configure SMTP email settings',
- build: (settings: IntegrationSettings) => (
-
- ),
- },
- {
- id: 'worker',
- titleKey: 'Worker Proxy',
- descriptionKey: 'Configure worker service settings',
- build: (settings: IntegrationSettings) => (
-
- ),
- },
- {
- id: 'ionet',
- titleKey: 'io.net Deployments',
- descriptionKey: 'Configure IoNet model deployment settings',
- build: (settings: IntegrationSettings) => (
-
- ),
- },
- {
- id: 'monitoring',
- titleKey: 'Monitoring & Alerts',
- descriptionKey: 'Configure channel monitoring and automation',
- build: (settings: IntegrationSettings) => (
-
- ),
- },
-] 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
diff --git a/web/default/src/features/system-settings/maintenance/config.ts b/web/default/src/features/system-settings/maintenance/config.ts
index dc2733a0..27c0e4bd 100644
--- a/web/default/src/features/system-settings/maintenance/config.ts
+++ b/web/default/src/features/system-settings/maintenance/config.ts
@@ -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
diff --git a/web/default/src/features/system-settings/maintenance/index.tsx b/web/default/src/features/system-settings/maintenance/index.tsx
deleted file mode 100644
index 5376b4e9..00000000
--- a/web/default/src/features/system-settings/maintenance/index.tsx
+++ /dev/null
@@ -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 (
-
- {t('Loading maintenance settings...')}
-
- )
- }
-
- 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 (
-
- )
-}
diff --git a/web/default/src/features/system-settings/maintenance/section-registry.tsx b/web/default/src/features/system-settings/maintenance/section-registry.tsx
deleted file mode 100644
index cf0a4a52..00000000
--- a/web/default/src/features/system-settings/maintenance/section-registry.tsx
+++ /dev/null
@@ -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
- ) => (
-
- ),
- },
- {
- id: 'notice',
- titleKey: 'System Notice',
- descriptionKey: 'Configure system maintenance notice',
- build: (settings: MaintenanceSettings) => (
-
- ),
- },
- {
- id: 'logs',
- titleKey: 'Log Maintenance',
- descriptionKey: 'Configure log consumption settings',
- build: (settings: MaintenanceSettings) => (
-
- ),
- },
- {
- id: 'header-navigation',
- titleKey: 'Header navigation',
- descriptionKey: 'Configure header navigation modules',
- build: (settings: MaintenanceSettings) => {
- const headerNavConfig = parseHeaderNavModules(settings.HeaderNavModules)
- const headerNavSerialized = serializeHeaderNavModules(headerNavConfig)
- return (
-
- )
- },
- },
- {
- 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 (
-
- )
- },
- },
- {
- id: 'performance',
- titleKey: 'Performance',
- descriptionKey: 'Disk cache, system monitoring and performance stats',
- build: (settings: MaintenanceSettings) => (
-
- ),
- },
-] 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
diff --git a/web/default/src/features/system-settings/models/index.tsx b/web/default/src/features/system-settings/models/index.tsx
index a0e9a1c6..4c3df9af 100644
--- a/web/default/src/features/system-settings/models/index.tsx
+++ b/web/default/src/features/system-settings/models/index.tsx
@@ -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() {
diff --git a/web/default/src/features/system-settings/models/model-pricing-sheet.tsx b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx
new file mode 100644
index 00000000..419a5eca
--- /dev/null
+++ b/web/default/src/features/system-settings/models/model-pricing-sheet.tsx
@@ -0,0 +1,1032 @@
+import { useEffect, useMemo, useState } from 'react'
+import * as z from 'zod'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { AlertTriangle, ChevronDown } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { cn } from '@/lib/utils'
+import { Alert, AlertDescription } from '@/components/ui/alert'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from '@/components/ui/collapsible'
+import {
+ Field,
+ FieldContent,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+ FieldTitle,
+} from '@/components/ui/field'
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form'
+import { Input } from '@/components/ui/input'
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupInput,
+} from '@/components/ui/input-group'
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet'
+import { Switch } from '@/components/ui/switch'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
+import { TieredPricingEditor } from './tiered-pricing-editor'
+
+const createModelPricingSchema = (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 ModelPricingFormValues = z.infer<
+ ReturnType
+>
+
+type PricingMode = 'per-token' | 'per-request' | 'tiered_expr'
+type LaneKey =
+ | 'completion'
+ | 'cache'
+ | 'createCache'
+ | 'image'
+ | 'audioInput'
+ | 'audioOutput'
+
+export type ModelRatioData = {
+ name: string
+ price?: string
+ ratio?: string
+ cacheRatio?: string
+ createCacheRatio?: string
+ completionRatio?: string
+ imageRatio?: string
+ audioRatio?: string
+ audioCompletionRatio?: string
+ billingMode?: PricingMode
+ billingExpr?: string
+ requestRuleExpr?: string
+}
+
+type ModelPricingSheetProps = {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ onSave: (data: ModelRatioData) => void
+ onCancel?: () => void
+ editData?: ModelRatioData | null
+ selectedTargetCount?: number
+}
+
+type ModelPricingEditorPanelProps = Omit<
+ ModelPricingSheetProps,
+ 'open' | 'onOpenChange'
+> & {
+ className?: string
+}
+
+type PreviewRow = {
+ key: string
+ label: string
+ value: string
+ multiline?: boolean
+}
+
+const numericDraftRegex = /^(\d+(\.\d*)?|\.\d*)?$/
+
+const EMPTY_LANE_PRICES: Record = {
+ completion: '',
+ cache: '',
+ createCache: '',
+ image: '',
+ audioInput: '',
+ audioOutput: '',
+}
+
+const EMPTY_LANE_ENABLED: Record = {
+ completion: false,
+ cache: false,
+ createCache: false,
+ image: false,
+ audioInput: false,
+ audioOutput: false,
+}
+
+const ratioFieldByLane: Record = {
+ completion: 'completionRatio',
+ cache: 'cacheRatio',
+ createCache: 'createCacheRatio',
+ image: 'imageRatio',
+ audioInput: 'audioRatio',
+ audioOutput: 'audioCompletionRatio',
+}
+
+const laneConfigs: Array<{
+ key: LaneKey
+ titleKey: string
+ descriptionKey: string
+ placeholder: string
+}> = [
+ {
+ key: 'completion',
+ titleKey: 'Completion price',
+ descriptionKey: 'Output token price for generated tokens.',
+ placeholder: '15',
+ },
+ {
+ key: 'cache',
+ titleKey: 'Cache read price',
+ descriptionKey: 'Token price for cache reads.',
+ placeholder: '0.3',
+ },
+ {
+ key: 'createCache',
+ titleKey: 'Cache write price',
+ descriptionKey: 'Token price for creating cache entries.',
+ placeholder: '3.75',
+ },
+ {
+ key: 'image',
+ titleKey: 'Image input price',
+ descriptionKey: 'Token price for image input.',
+ placeholder: '2.5',
+ },
+ {
+ key: 'audioInput',
+ titleKey: 'Audio input price',
+ descriptionKey: 'Token price for audio input.',
+ placeholder: '3.81',
+ },
+ {
+ key: 'audioOutput',
+ titleKey: 'Audio output price',
+ descriptionKey: 'Token price for audio output.',
+ placeholder: '15.11',
+ },
+]
+
+function hasValue(value: unknown): boolean {
+ return (
+ value !== '' && value !== null && value !== undefined && value !== false
+ )
+}
+
+function toNumberOrNull(value: unknown): number | null {
+ if (!hasValue(value) && value !== 0) return null
+ const num = Number(value)
+ return Number.isFinite(num) ? num : null
+}
+
+function formatNumber(value: unknown): string {
+ const num = toNumberOrNull(value)
+ if (num === null) return ''
+ return Number.parseFloat(num.toFixed(12)).toString()
+}
+
+function ratioToBasePrice(ratio: unknown): string {
+ const num = toNumberOrNull(ratio)
+ if (num === null) return ''
+ return formatNumber(num * 2)
+}
+
+function deriveLanePrice(
+ ratio: unknown,
+ denominator: unknown,
+ fallback = ''
+): string {
+ const ratioNumber = toNumberOrNull(ratio)
+ const denominatorNumber = toNumberOrNull(denominator)
+ if (ratioNumber === null || denominatorNumber === null) return fallback
+ return formatNumber(ratioNumber * denominatorNumber)
+}
+
+function createInitialLaneState(data?: ModelRatioData | null) {
+ if (!data) {
+ return {
+ promptPrice: '',
+ prices: { ...EMPTY_LANE_PRICES },
+ enabled: { ...EMPTY_LANE_ENABLED },
+ }
+ }
+
+ const promptPrice = ratioToBasePrice(data.ratio)
+ const audioInputPrice = deriveLanePrice(data.audioRatio, promptPrice)
+ const prices: Record = {
+ completion: deriveLanePrice(data.completionRatio, promptPrice),
+ cache: deriveLanePrice(data.cacheRatio, promptPrice),
+ createCache: deriveLanePrice(data.createCacheRatio, promptPrice),
+ image: deriveLanePrice(data.imageRatio, promptPrice),
+ audioInput: audioInputPrice,
+ audioOutput: deriveLanePrice(data.audioCompletionRatio, audioInputPrice),
+ }
+
+ return {
+ promptPrice,
+ prices,
+ enabled: {
+ completion: hasValue(data.completionRatio),
+ cache: hasValue(data.cacheRatio),
+ createCache: hasValue(data.createCacheRatio),
+ image: hasValue(data.imageRatio),
+ audioInput: hasValue(data.audioRatio),
+ audioOutput: hasValue(data.audioCompletionRatio),
+ },
+ }
+}
+
+function getModeLabel(mode: PricingMode) {
+ if (mode === 'per-request') return 'Per-request'
+ if (mode === 'tiered_expr') return 'Expression'
+ return 'Per-token'
+}
+
+function getModeBadgeVariant(
+ mode: PricingMode
+): 'default' | 'secondary' | 'outline' {
+ if (mode === 'per-request') return 'secondary'
+ if (mode === 'tiered_expr') return 'default'
+ return 'outline'
+}
+
+function truncateExpr(value: string) {
+ if (!value) return ''
+ return value.length > 110 ? `${value.slice(0, 110)}...` : value
+}
+
+function buildPreviewRows(
+ values: ModelPricingFormValues,
+ mode: PricingMode,
+ billingExpr: string,
+ requestRuleExpr: string,
+ promptPrice: string,
+ lanePrices: Record,
+ laneEnabled: Record,
+ t: (key: string) => string
+): PreviewRow[] {
+ if (mode === 'tiered_expr') {
+ const effectiveExpr = combineBillingExpr(billingExpr, requestRuleExpr)
+ return [
+ { key: 'mode', label: 'BillingMode', value: 'tiered_expr' },
+ {
+ key: 'expr',
+ label: t('Expression'),
+ value: effectiveExpr || t('Empty'),
+ multiline: true,
+ },
+ ]
+ }
+
+ if (mode === 'per-request') {
+ return [
+ {
+ key: 'price',
+ label: 'ModelPrice',
+ value: values.price || t('Empty'),
+ },
+ ]
+ }
+
+ return [
+ {
+ key: 'inputPrice',
+ label: t('Input price'),
+ value: promptPrice ? `$${promptPrice}` : t('Empty'),
+ },
+ {
+ key: 'completion',
+ label: t('Completion price'),
+ value:
+ laneEnabled.completion && lanePrices.completion
+ ? `$${lanePrices.completion}`
+ : t('Empty'),
+ },
+ {
+ key: 'cache',
+ label: t('Cache read price'),
+ value:
+ laneEnabled.cache && lanePrices.cache
+ ? `$${lanePrices.cache}`
+ : t('Empty'),
+ },
+ {
+ key: 'createCache',
+ label: t('Cache write price'),
+ value:
+ laneEnabled.createCache && lanePrices.createCache
+ ? `$${lanePrices.createCache}`
+ : t('Empty'),
+ },
+ {
+ key: 'image',
+ label: t('Image input price'),
+ value:
+ laneEnabled.image && lanePrices.image
+ ? `$${lanePrices.image}`
+ : t('Empty'),
+ },
+ {
+ key: 'audio',
+ label: t('Audio input price'),
+ value:
+ laneEnabled.audioInput && lanePrices.audioInput
+ ? `$${lanePrices.audioInput}`
+ : t('Empty'),
+ },
+ {
+ key: 'audioCompletion',
+ label: t('Audio output price'),
+ value:
+ laneEnabled.audioOutput && lanePrices.audioOutput
+ ? `$${lanePrices.audioOutput}`
+ : t('Empty'),
+ },
+ ]
+}
+
+export function ModelPricingSheet({
+ open,
+ onOpenChange,
+ onSave,
+ onCancel,
+ editData,
+ selectedTargetCount = 0,
+}: ModelPricingSheetProps) {
+ const { t } = useTranslation()
+ const title = editData ? t('Edit model pricing') : t('Add model pricing')
+ const description = editData?.name || t('New model')
+
+ return (
+
+
+
+ {title}
+ {description}
+
+ {
+ onCancel?.()
+ onOpenChange(false)
+ }}
+ className='h-full rounded-none border-0'
+ />
+
+
+ )
+}
+
+export function ModelPricingEditorPanel({
+ onSave,
+ editData,
+ selectedTargetCount = 0,
+ onCancel,
+ className,
+}: ModelPricingEditorPanelProps) {
+ const { t } = useTranslation()
+ const [pricingMode, setPricingMode] = useState('per-token')
+ const [promptPrice, setPromptPrice] = useState('')
+ const [lanePrices, setLanePrices] = useState>({
+ ...EMPTY_LANE_PRICES,
+ })
+ const [laneEnabled, setLaneEnabled] = useState>({
+ ...EMPTY_LANE_ENABLED,
+ })
+ const [billingExpr, setBillingExpr] = useState('')
+ const [requestRuleExpr, setRequestRuleExpr] = useState('')
+ const [previewOpen, setPreviewOpen] = useState(true)
+ const isEditMode = !!editData
+
+ const form = useForm({
+ resolver: zodResolver(createModelPricingSchema(t)),
+ defaultValues: {
+ name: '',
+ price: '',
+ ratio: '',
+ cacheRatio: '',
+ createCacheRatio: '',
+ completionRatio: '',
+ imageRatio: '',
+ audioRatio: '',
+ audioCompletionRatio: '',
+ },
+ })
+
+ useEffect(() => {
+ const nextLaneState = createInitialLaneState(editData)
+
+ if (editData) {
+ form.reset({
+ name: editData.name,
+ price: editData.price || '',
+ ratio: editData.ratio || '',
+ cacheRatio: editData.cacheRatio || '',
+ createCacheRatio: editData.createCacheRatio || '',
+ completionRatio: editData.completionRatio || '',
+ imageRatio: editData.imageRatio || '',
+ audioRatio: editData.audioRatio || '',
+ audioCompletionRatio: editData.audioCompletionRatio || '',
+ })
+ setPricingMode(
+ editData.billingMode === 'tiered_expr'
+ ? 'tiered_expr'
+ : editData.price
+ ? 'per-request'
+ : 'per-token'
+ )
+ setBillingExpr(editData.billingExpr || '')
+ setRequestRuleExpr(editData.requestRuleExpr || '')
+ } else {
+ form.reset({
+ name: '',
+ price: '',
+ ratio: '',
+ cacheRatio: '',
+ createCacheRatio: '',
+ completionRatio: '',
+ imageRatio: '',
+ audioRatio: '',
+ audioCompletionRatio: '',
+ })
+ setPricingMode('per-token')
+ setBillingExpr('')
+ setRequestRuleExpr('')
+ }
+
+ setPromptPrice(nextLaneState.promptPrice)
+ setLanePrices(nextLaneState.prices)
+ setLaneEnabled(nextLaneState.enabled)
+ setPreviewOpen(true)
+ }, [editData, form])
+
+ const setFormValue = (field: keyof ModelPricingFormValues, value: string) => {
+ form.setValue(field, value, {
+ shouldDirty: true,
+ shouldValidate: true,
+ })
+ }
+
+ const deriveLaneRatio = (
+ lane: LaneKey,
+ price: string,
+ nextPromptPrice = promptPrice,
+ nextLanePrices = lanePrices
+ ) => {
+ const priceNumber = toNumberOrNull(price)
+ if (priceNumber === null) return ''
+
+ if (lane === 'audioOutput') {
+ const audioInputPrice = toNumberOrNull(nextLanePrices.audioInput)
+ if (audioInputPrice === null || audioInputPrice === 0) return ''
+ return formatNumber(priceNumber / audioInputPrice)
+ }
+
+ const inputPrice = toNumberOrNull(nextPromptPrice)
+ if (inputPrice === null || inputPrice === 0) return ''
+ return formatNumber(priceNumber / inputPrice)
+ }
+
+ const syncLaneRatios = (
+ nextPromptPrice = promptPrice,
+ nextLanePrices = lanePrices,
+ nextLaneEnabled = laneEnabled
+ ) => {
+ const inputPrice = toNumberOrNull(nextPromptPrice)
+ setFormValue(
+ 'ratio',
+ inputPrice !== null ? formatNumber(inputPrice / 2) : ''
+ )
+
+ laneConfigs.forEach(({ key }) => {
+ const ratioField = ratioFieldByLane[key]
+ if (!nextLaneEnabled[key]) {
+ setFormValue(ratioField, '')
+ return
+ }
+ setFormValue(
+ ratioField,
+ deriveLaneRatio(
+ key,
+ nextLanePrices[key],
+ nextPromptPrice,
+ nextLanePrices
+ )
+ )
+ })
+ }
+
+ const handlePromptPriceChange = (value: string) => {
+ if (!numericDraftRegex.test(value)) return
+ setPromptPrice(value)
+ syncLaneRatios(value, lanePrices, laneEnabled)
+ }
+
+ const handleLanePriceChange = (lane: LaneKey, value: string) => {
+ if (!numericDraftRegex.test(value)) return
+ const nextLanePrices = { ...lanePrices, [lane]: value }
+ setLanePrices(nextLanePrices)
+
+ if (laneEnabled[lane]) {
+ setFormValue(
+ ratioFieldByLane[lane],
+ deriveLaneRatio(lane, value, promptPrice, nextLanePrices)
+ )
+ }
+
+ if (lane === 'audioInput' && laneEnabled.audioOutput) {
+ setFormValue(
+ 'audioCompletionRatio',
+ deriveLaneRatio(
+ 'audioOutput',
+ nextLanePrices.audioOutput,
+ promptPrice,
+ nextLanePrices
+ )
+ )
+ }
+ }
+
+ const handleLaneToggle = (lane: LaneKey, checked: boolean) => {
+ const nextEnabled = { ...laneEnabled, [lane]: checked }
+ let nextPrices = lanePrices
+
+ if (!checked) {
+ nextPrices = { ...nextPrices, [lane]: '' }
+ setFormValue(ratioFieldByLane[lane], '')
+ if (lane === 'audioInput') {
+ nextEnabled.audioOutput = false
+ nextPrices.audioOutput = ''
+ setFormValue('audioCompletionRatio', '')
+ }
+ }
+
+ setLaneEnabled(nextEnabled)
+ setLanePrices(nextPrices)
+
+ if (checked) {
+ setFormValue(
+ ratioFieldByLane[lane],
+ deriveLaneRatio(lane, nextPrices[lane], promptPrice, nextPrices)
+ )
+ }
+ }
+
+ const handleModeChange = (value: string) => {
+ const nextMode = value as PricingMode
+ setPricingMode(nextMode)
+ if (nextMode === 'tiered_expr' && !billingExpr) {
+ setBillingExpr('tier("base", p * 0 + c * 0)')
+ }
+ }
+
+ const watchedValues = form.watch()
+ const previewRows = useMemo(
+ () =>
+ buildPreviewRows(
+ watchedValues,
+ pricingMode,
+ billingExpr,
+ requestRuleExpr,
+ promptPrice,
+ lanePrices,
+ laneEnabled,
+ t
+ ),
+ [
+ billingExpr,
+ laneEnabled,
+ lanePrices,
+ pricingMode,
+ promptPrice,
+ requestRuleExpr,
+ t,
+ watchedValues,
+ ]
+ )
+
+ const warnings = useMemo(() => {
+ const nextWarnings: string[] = []
+ const hasConflict =
+ !!editData?.price &&
+ [
+ editData.ratio,
+ editData.completionRatio,
+ editData.cacheRatio,
+ editData.createCacheRatio,
+ editData.imageRatio,
+ editData.audioRatio,
+ editData.audioCompletionRatio,
+ ].some(hasValue)
+
+ if (hasConflict) {
+ nextWarnings.push(
+ t(
+ 'This model has both fixed-price and token-price settings. Saving the current mode will rewrite the conflicting fields.'
+ )
+ )
+ }
+
+ if (
+ pricingMode === 'per-token' &&
+ toNumberOrNull(promptPrice) === null &&
+ laneConfigs.some(
+ ({ key }) => laneEnabled[key] && hasValue(lanePrices[key])
+ )
+ ) {
+ nextWarnings.push(
+ t('Input price is required before saving dependent prices.')
+ )
+ }
+
+ if (
+ pricingMode === 'per-token' &&
+ laneEnabled.audioOutput &&
+ !hasValue(lanePrices.audioInput)
+ ) {
+ nextWarnings.push(t('Audio output price requires an audio input price.'))
+ }
+
+ return nextWarnings
+ }, [editData, laneEnabled, lanePrices, pricingMode, promptPrice, t])
+
+ const handleSubmit = (values: ModelPricingFormValues) => {
+ if (
+ pricingMode === 'per-token' &&
+ toNumberOrNull(promptPrice) === null &&
+ laneConfigs.some(
+ ({ key }) => laneEnabled[key] && hasValue(lanePrices[key])
+ )
+ ) {
+ form.setError('ratio', {
+ message: t('Input price is required before saving dependent prices.'),
+ })
+ return
+ }
+
+ if (
+ pricingMode === 'per-token' &&
+ laneEnabled.audioOutput &&
+ !hasValue(lanePrices.audioInput)
+ ) {
+ form.setError('audioRatio', {
+ message: t('Audio output price requires an audio input price.'),
+ })
+ return
+ }
+
+ const data: ModelRatioData = {
+ name: values.name.trim(),
+ 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()
+ onCancel?.()
+ }
+
+ const activeName = watchedValues.name || editData?.name || t('New model')
+
+ return (
+
+
+
+
+
+ {isEditMode ? t('Edit model pricing') : t('Add model pricing')}
+
+
+ {activeName}
+
+
+
+ {t(getModeLabel(pricingMode))}
+
+
+
+
+
+
+
+ )
+}
+
+function PriceInput(props: {
+ value: string
+ placeholder?: string
+ disabled?: boolean
+ onChange: (value: string) => void
+}) {
+ return (
+
+ $
+ props.onChange(event.target.value)}
+ />
+ $/1M
+
+ )
+}
+
+function PriceLane(props: {
+ title: string
+ description: string
+ placeholder: string
+ value: string
+ enabled: boolean
+ disabled?: boolean
+ onEnabledChange: (checked: boolean) => void
+ onChange: (value: string) => void
+}) {
+ const { t } = useTranslation()
+ const effectiveDisabled = props.disabled || !props.enabled
+
+ return (
+
+
+
+ {props.title}
+ {props.description}
+
+
+
+
+
+ {props.enabled
+ ? t('USD price per 1M tokens.')
+ : t('Disabled lanes are omitted on save.')}
+
+
+ )
+}
diff --git a/web/default/src/features/system-settings/models/model-ratio-dialog.tsx b/web/default/src/features/system-settings/models/model-ratio-dialog.tsx
deleted file mode 100644
index 42c4c5e7..00000000
--- a/web/default/src/features/system-settings/models/model-ratio-dialog.tsx
+++ /dev/null
@@ -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>
-
-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('per-token')
- const [pricingSubMode, setPricingSubMode] = useState('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({
- 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 (
-
- )
-}
diff --git a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx
index ce6b2233..8b8fd55c 100644
--- a/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx
+++ b/web/default/src/features/system-settings/models/model-ratio-visual-editor.tsx
@@ -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(null)
const [sorting, setSorting] = useState([])
+ const [columnFilters, setColumnFilters] = useState([])
+ const [globalFilter, setGlobalFilter] = useState('')
+ const [rowSelection, setRowSelection] = useState({})
const [pagination, setPagination] = useState({
pageIndex: 0,
- pageSize: 10,
+ pageSize: 20,
})
const [columnVisibility, setColumnVisibility] = useState(
() => {
@@ -102,6 +212,7 @@ export const ModelRatioVisualEditor = memo(
return safeJsonParse(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>(
+ (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>(modelPrice, {
@@ -383,15 +542,32 @@ export const ModelRatioVisualEditor = memo(
)
const columns = useMemo[]>(() => {
- // 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 }) => (
+
+ table.toggleAllPageRowsSelected(!!value)
+ }
+ aria-label={t('Select all')}
+ className='translate-y-[2px]'
+ />
+ ),
+ cell: ({ row }) => (
+ 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 }) => (
-
+
),
cell: ({ row }) => (
-
- {formatValue(row.getValue('price'))}
-
+
),
- meta: { label: 'Fixed price' },
+ filterFn: (row, id, value) =>
+ filterBySelectedValues(row.getValue(id), value),
+ meta: { label: t('Mode') },
},
{
- accessorKey: 'ratio',
+ id: 'priceSummary',
header: ({ column }) => (
-
+
),
cell: ({ row }) => (
-
- {formatValue(row.getValue('ratio'))}
-
+
+
+ {getPriceSummary(row.original, t)}
+
+
+ {getPriceDetail(row.original, t)}
+
+
),
- meta: { label: 'Ratio' },
- },
- {
- accessorKey: 'completionRatio',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {formatValue(row.getValue('completionRatio'))}
-
- ),
- meta: { label: 'Completion' },
- },
- {
- accessorKey: 'cacheRatio',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {formatValue(row.getValue('cacheRatio'))}
-
- ),
- meta: { label: 'Cache' },
- },
- {
- accessorKey: 'createCacheRatio',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {formatValue(row.getValue('createCacheRatio'))}
-
- ),
- meta: { label: 'Create cache' },
- },
- {
- accessorKey: 'imageRatio',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {formatValue(row.getValue('imageRatio'))}
-
- ),
- meta: { label: 'Image' },
- },
- {
- accessorKey: 'audioRatio',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {formatValue(row.getValue('audioRatio'))}
-
- ),
- meta: { label: 'Audio' },
- },
- {
- accessorKey: 'audioCompletionRatio',
- header: ({ column }) => (
-
- ),
- cell: ({ row }) => (
-
- {formatValue(row.getValue('audioCompletionRatio'))}
-
- ),
- 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)}
>
-
+
),
@@ -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