Files
new-api/web/default/src/features/system-settings/models/ratio-settings-card.tsx
T
QuentinHsu 4dd68bad52 perf(model-pricing): move pricing tabs into page title
- place the model pricing tab switcher beside the page title instead of spanning the content area.
- keep the switcher width tied to its labels while preserving spacing around title status content.
2026-06-06 15:26:53 +08:00

490 lines
17 KiB
TypeScript
Vendored

/*
Copyright (C) 2023-2026 QuantumNous
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
For commercial licensing, please contact support@quantumnous.com
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as z from 'zod'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { resetModelRatios } from '../api'
import { SettingsPageTitleStatusPortal } from '../components/settings-page-context'
import { SettingsSection } from '../components/settings-section'
import { useUpdateOption } from '../hooks/use-update-option'
import { GroupRatioForm } from './group-ratio-form'
import { ModelRatioForm } from './model-ratio-form'
import { ToolPriceSettings } from './tool-price-settings'
import { UpstreamRatioSync } from './upstream-ratio-sync'
import {
formatJsonForTextarea,
type JsonValidationError,
normalizeJsonString,
validateJsonString,
} from './utils'
type Translate = (key: string, options?: Record<string, unknown>) => string
function formatJsonValidationError(
t: Translate,
error?: JsonValidationError,
fallback = 'Invalid JSON'
) {
if (!error) return t(fallback)
if (error.type === 'required') return t('Value is required')
if (error.type === 'structure') {
return t(
fallback === 'Invalid JSON' ? 'JSON structure is invalid' : fallback
)
}
const parts = [
error.line && error.column
? t('JSON is invalid at line {{line}}, column {{column}}.', {
line: error.line,
column: error.column,
})
: error.position !== undefined
? t('JSON is invalid at position {{position}}.', {
position: error.position,
})
: t('JSON is invalid. Please check the syntax.'),
]
if (error.missingCommaLine) {
parts.push(
t('Check line {{line}} for a missing comma.', {
line: error.missingCommaLine,
})
)
}
return parts.join(' ')
}
function createJsonStringField(
t: Translate,
options?: Parameters<typeof validateJsonString>[1]
) {
return z.string().superRefine((value, ctx) => {
const result = validateJsonString(value, options)
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: formatJsonValidationError(t, result.error, result.message),
})
}
})
}
const createModelSchema = (t: Translate) =>
z.object({
ModelPrice: createJsonStringField(t),
ModelRatio: createJsonStringField(t),
CacheRatio: createJsonStringField(t),
CreateCacheRatio: createJsonStringField(t),
CompletionRatio: createJsonStringField(t),
ImageRatio: createJsonStringField(t),
AudioRatio: createJsonStringField(t),
AudioCompletionRatio: createJsonStringField(t),
ExposeRatioEnabled: z.boolean(),
BillingMode: createJsonStringField(t),
BillingExpr: createJsonStringField(t),
})
const createGroupSchema = (t: Translate) =>
z.object({
GroupRatio: createJsonStringField(t),
TopupGroupRatio: createJsonStringField(t),
UserUsableGroups: createJsonStringField(t),
GroupGroupRatio: createJsonStringField(t),
AutoGroups: createJsonStringField(t, {
predicate: (parsed) =>
Array.isArray(parsed) &&
parsed.every((item) => typeof item === 'string'),
predicateMessage: 'Expected a JSON array of group identifiers',
}),
DefaultUseAutoGroup: z.boolean(),
GroupSpecialUsableGroup: createJsonStringField(t),
})
type ModelFormValues = z.infer<ReturnType<typeof createModelSchema>>
type GroupFormValues = z.infer<ReturnType<typeof createGroupSchema>>
type RatioTabId = 'models' | 'groups' | 'tool-prices' | 'upstream-sync'
type RatioSettingsCardProps = {
modelDefaults: ModelFormValues
groupDefaults: GroupFormValues
toolPricesDefault: string
titleKey?: string
visibleTabs?: RatioTabId[]
}
export function RatioSettingsCard({
modelDefaults,
groupDefaults,
toolPricesDefault,
titleKey = 'Pricing Ratios',
visibleTabs = ['models', 'groups', 'tool-prices', 'upstream-sync'],
}: RatioSettingsCardProps) {
const { t } = useTranslation()
const updateOption = useUpdateOption()
const queryClient = useQueryClient()
const [confirmOpen, setConfirmOpen] = useState(false)
const resetMutation = useMutation({
mutationFn: resetModelRatios,
onSuccess: (data) => {
if (data.success) {
toast.success(t('Model prices reset successfully'))
queryClient.invalidateQueries({ queryKey: ['system-options'] })
setConfirmOpen(false)
} else {
toast.error(data.message || t('Failed to reset model ratios'))
}
},
onError: (error: Error) => {
toast.error(error.message || t('Failed to reset model ratios'))
},
})
const modelNormalizedDefaults = useRef({
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
AudioCompletionRatio: normalizeJsonString(
modelDefaults.AudioCompletionRatio
),
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
})
const [savedModelValues, setSavedModelValues] = useState(
modelNormalizedDefaults.current
)
const groupNormalizedDefaults = useRef({
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
),
})
const modelSchema = useMemo(() => createModelSchema(t), [t])
const groupSchema = useMemo(() => createGroupSchema(t), [t])
const modelForm = useForm<ModelFormValues>({
resolver: zodResolver(modelSchema),
mode: 'onChange',
defaultValues: {
...modelDefaults,
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
AudioCompletionRatio: formatJsonForTextarea(
modelDefaults.AudioCompletionRatio
),
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
},
})
const groupForm = useForm<GroupFormValues>({
resolver: zodResolver(groupSchema),
mode: 'onChange',
defaultValues: {
...groupDefaults,
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
),
},
})
useEffect(() => {
modelNormalizedDefaults.current = {
ModelPrice: normalizeJsonString(modelDefaults.ModelPrice),
ModelRatio: normalizeJsonString(modelDefaults.ModelRatio),
CacheRatio: normalizeJsonString(modelDefaults.CacheRatio),
CreateCacheRatio: normalizeJsonString(modelDefaults.CreateCacheRatio),
CompletionRatio: normalizeJsonString(modelDefaults.CompletionRatio),
ImageRatio: normalizeJsonString(modelDefaults.ImageRatio),
AudioRatio: normalizeJsonString(modelDefaults.AudioRatio),
AudioCompletionRatio: normalizeJsonString(
modelDefaults.AudioCompletionRatio
),
ExposeRatioEnabled: modelDefaults.ExposeRatioEnabled,
BillingMode: normalizeJsonString(modelDefaults.BillingMode),
BillingExpr: normalizeJsonString(modelDefaults.BillingExpr),
}
setSavedModelValues(modelNormalizedDefaults.current)
modelForm.reset({
...modelDefaults,
ModelPrice: formatJsonForTextarea(modelDefaults.ModelPrice),
ModelRatio: formatJsonForTextarea(modelDefaults.ModelRatio),
CacheRatio: formatJsonForTextarea(modelDefaults.CacheRatio),
CreateCacheRatio: formatJsonForTextarea(modelDefaults.CreateCacheRatio),
CompletionRatio: formatJsonForTextarea(modelDefaults.CompletionRatio),
ImageRatio: formatJsonForTextarea(modelDefaults.ImageRatio),
AudioRatio: formatJsonForTextarea(modelDefaults.AudioRatio),
AudioCompletionRatio: formatJsonForTextarea(
modelDefaults.AudioCompletionRatio
),
BillingMode: formatJsonForTextarea(modelDefaults.BillingMode),
BillingExpr: formatJsonForTextarea(modelDefaults.BillingExpr),
})
}, [modelDefaults, modelForm])
useEffect(() => {
groupNormalizedDefaults.current = {
GroupRatio: normalizeJsonString(groupDefaults.GroupRatio),
TopupGroupRatio: normalizeJsonString(groupDefaults.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(groupDefaults.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(groupDefaults.GroupGroupRatio),
AutoGroups: normalizeJsonString(groupDefaults.AutoGroups),
DefaultUseAutoGroup: groupDefaults.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
groupDefaults.GroupSpecialUsableGroup
),
}
groupForm.reset({
...groupDefaults,
GroupRatio: formatJsonForTextarea(groupDefaults.GroupRatio),
TopupGroupRatio: formatJsonForTextarea(groupDefaults.TopupGroupRatio),
UserUsableGroups: formatJsonForTextarea(groupDefaults.UserUsableGroups),
GroupGroupRatio: formatJsonForTextarea(groupDefaults.GroupGroupRatio),
AutoGroups: formatJsonForTextarea(groupDefaults.AutoGroups),
GroupSpecialUsableGroup: formatJsonForTextarea(
groupDefaults.GroupSpecialUsableGroup
),
})
}, [groupDefaults, groupForm])
const saveModelRatios = useCallback(
async (values: ModelFormValues) => {
const normalized = {
ModelPrice: normalizeJsonString(values.ModelPrice),
ModelRatio: normalizeJsonString(values.ModelRatio),
CacheRatio: normalizeJsonString(values.CacheRatio),
CreateCacheRatio: normalizeJsonString(values.CreateCacheRatio),
CompletionRatio: normalizeJsonString(values.CompletionRatio),
ImageRatio: normalizeJsonString(values.ImageRatio),
AudioRatio: normalizeJsonString(values.AudioRatio),
AudioCompletionRatio: normalizeJsonString(values.AudioCompletionRatio),
ExposeRatioEnabled: values.ExposeRatioEnabled,
BillingMode: normalizeJsonString(values.BillingMode),
BillingExpr: normalizeJsonString(values.BillingExpr),
}
const apiKeyMap: Record<string, string> = {
BillingMode: 'billing_setting.billing_mode',
BillingExpr: 'billing_setting.billing_expr',
}
const updates = (
Object.keys(normalized) as Array<keyof ModelFormValues>
).filter(
(key) => normalized[key] !== modelNormalizedDefaults.current[key]
)
if (updates.length === 0) {
toast.info(t('No model price changes to save'))
return
}
for (const key of updates) {
const apiKey = apiKeyMap[key as string] || (key as string)
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
modelNormalizedDefaults.current = normalized
setSavedModelValues(normalized)
},
[t, updateOption]
)
const saveGroupRatios = useCallback(
async (values: GroupFormValues) => {
const normalized = {
GroupRatio: normalizeJsonString(values.GroupRatio),
TopupGroupRatio: normalizeJsonString(values.TopupGroupRatio),
UserUsableGroups: normalizeJsonString(values.UserUsableGroups),
GroupGroupRatio: normalizeJsonString(values.GroupGroupRatio),
AutoGroups: normalizeJsonString(values.AutoGroups),
DefaultUseAutoGroup: values.DefaultUseAutoGroup,
GroupSpecialUsableGroup: normalizeJsonString(
values.GroupSpecialUsableGroup
),
}
// Map form field names to API keys (most are 1:1, except GroupSpecialUsableGroup)
const apiKeyMap: Record<string, string> = {
GroupSpecialUsableGroup:
'group_ratio_setting.group_special_usable_group',
}
const updates = (
Object.keys(normalized) as Array<keyof typeof normalized>
).filter(
(key) => normalized[key] !== groupNormalizedDefaults.current[key]
)
for (const key of updates) {
const apiKey = apiKeyMap[key] || key
await updateOption.mutateAsync({ key: apiKey, value: normalized[key] })
}
},
[updateOption]
)
const handleResetRatios = useCallback(() => {
setConfirmOpen(true)
}, [])
const { mutate: resetMutate } = resetMutation
const handleConfirmReset = useCallback(() => {
resetMutate()
}, [resetMutate])
const tabLabels: Record<RatioTabId, string> = {
models: 'Model prices',
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}
savedValues={savedModelValues}
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,
}}
/>
)
}
const renderTabSwitcher = () => (
<TabsList className={`grid w-fit max-w-full ${tabsGridClass}`}>
{visibleTabs.map((tab) => (
<TabsTrigger key={tab} value={tab}>
{t(tabLabels[tab])}
</TabsTrigger>
))}
</TabsList>
)
return (
<>
{visibleTabs.length === 1 ? (
<SettingsSection title={t(titleKey)}>
{renderTabContent(defaultTab)}
</SettingsSection>
) : (
<Tabs defaultValue={defaultTab} className='space-y-6'>
<SettingsPageTitleStatusPortal>
{renderTabSwitcher()}
</SettingsPageTitleStatusPortal>
<SettingsSection title={t(titleKey)}>
{visibleTabs.map((tab) => (
<TabsContent key={tab} value={tab}>
{renderTabContent(tab)}
</TabsContent>
))}
</SettingsSection>
</Tabs>
)}
<ConfirmDialog
open={confirmOpen}
onOpenChange={setConfirmOpen}
title={t('Reset all model prices?')}
desc={t(
'This will clear custom pricing ratios and revert to upstream defaults.'
)}
destructive
isLoading={resetMutation.isPending}
handleConfirm={handleConfirmReset}
confirmText={t('Reset')}
/>
</>
)
}