/* 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 . 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 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[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> type GroupFormValues = z.infer> 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({ 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({ 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 = { BillingMode: 'billing_setting.billing_mode', BillingExpr: 'billing_setting.billing_expr', } const updates = ( Object.keys(normalized) as Array ).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 = { GroupSpecialUsableGroup: 'group_ratio_setting.group_special_usable_group', } const updates = ( Object.keys(normalized) as Array ).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 = { 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 ( ) } if (tab === 'groups') { return ( ) } if (tab === 'tool-prices') { return } return ( ) } const renderTabSwitcher = () => ( {visibleTabs.map((tab) => ( {t(tabLabels[tab])} ))} ) return ( <> {visibleTabs.length === 1 ? ( {renderTabContent(defaultTab)} ) : ( {renderTabSwitcher()} {visibleTabs.map((tab) => ( {renderTabContent(tab)} ))} )} ) }