- 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.
490 lines
17 KiB
TypeScript
Vendored
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')}
|
|
/>
|
|
</>
|
|
)
|
|
}
|