Redesign the system settings interface to align with the rest of the console experience by using fixed header actions, removing redundant subtitles, respecting global content width, and standardizing responsive form layouts. Introduce reusable settings layout primitives for forms, switch rows, grouped controls, nested control sections, title status indicators, and page action portals. Replace duplicated card-style switch markup with explicit compact components, improve nested switch readability, and reduce visual noise across authentication, billing, content, integrations, maintenance, models, and request-limit settings. Also complete missing i18n translations, remove obsolete subtitle translation keys, refine i18n sync reporting, fix sidebar truncation for long labels, and verify the frontend with type checking and lint diagnostics.
1035 lines
30 KiB
TypeScript
Vendored
1035 lines
30 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 { 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,
|
|
FieldDescription,
|
|
FieldGroup,
|
|
FieldLabel,
|
|
} 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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
|
|
import {
|
|
SettingsControlGroup,
|
|
SettingsSwitchField,
|
|
} from '../components/settings-form-layout'
|
|
import { formatPricingNumber } from './pricing-format'
|
|
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<typeof createModelPricingSchema>
|
|
>
|
|
|
|
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<LaneKey, string> = {
|
|
completion: '',
|
|
cache: '',
|
|
createCache: '',
|
|
image: '',
|
|
audioInput: '',
|
|
audioOutput: '',
|
|
}
|
|
|
|
const EMPTY_LANE_ENABLED: Record<LaneKey, boolean> = {
|
|
completion: false,
|
|
cache: false,
|
|
createCache: false,
|
|
image: false,
|
|
audioInput: false,
|
|
audioOutput: false,
|
|
}
|
|
|
|
const ratioFieldByLane: Record<LaneKey, keyof ModelPricingFormValues> = {
|
|
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 ratioToBasePrice(ratio: unknown): string {
|
|
const num = toNumberOrNull(ratio)
|
|
if (num === null) return ''
|
|
return formatPricingNumber(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 formatPricingNumber(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<LaneKey, string> = {
|
|
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 buildPreviewRows(
|
|
values: ModelPricingFormValues,
|
|
mode: PricingMode,
|
|
billingExpr: string,
|
|
requestRuleExpr: string,
|
|
promptPrice: string,
|
|
lanePrices: Record<LaneKey, string>,
|
|
laneEnabled: Record<LaneKey, boolean>,
|
|
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 (
|
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
<SheetContent side='right' className='w-full gap-0 p-0 sm:max-w-2xl'>
|
|
<SheetHeader className='sr-only'>
|
|
<SheetTitle>{title}</SheetTitle>
|
|
<SheetDescription>{description}</SheetDescription>
|
|
</SheetHeader>
|
|
<ModelPricingEditorPanel
|
|
onSave={onSave}
|
|
editData={editData}
|
|
selectedTargetCount={selectedTargetCount}
|
|
onCancel={() => {
|
|
onCancel?.()
|
|
onOpenChange(false)
|
|
}}
|
|
className='h-full rounded-none border-0'
|
|
/>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
}
|
|
|
|
export function ModelPricingEditorPanel({
|
|
onSave,
|
|
editData,
|
|
selectedTargetCount = 0,
|
|
onCancel,
|
|
className,
|
|
}: ModelPricingEditorPanelProps) {
|
|
const { t } = useTranslation()
|
|
const [pricingMode, setPricingMode] = useState<PricingMode>('per-token')
|
|
const [promptPrice, setPromptPrice] = useState('')
|
|
const [lanePrices, setLanePrices] = useState<Record<LaneKey, string>>({
|
|
...EMPTY_LANE_PRICES,
|
|
})
|
|
const [laneEnabled, setLaneEnabled] = useState<Record<LaneKey, boolean>>({
|
|
...EMPTY_LANE_ENABLED,
|
|
})
|
|
const [billingExpr, setBillingExpr] = useState('')
|
|
const [requestRuleExpr, setRequestRuleExpr] = useState('')
|
|
const [previewOpen, setPreviewOpen] = useState(true)
|
|
const isEditMode = !!editData
|
|
|
|
const form = useForm<ModelPricingFormValues>({
|
|
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 formatPricingNumber(priceNumber / audioInputPrice)
|
|
}
|
|
|
|
const inputPrice = toNumberOrNull(nextPromptPrice)
|
|
if (inputPrice === null || inputPrice === 0) return ''
|
|
return formatPricingNumber(priceNumber / inputPrice)
|
|
}
|
|
|
|
const syncLaneRatios = (
|
|
nextPromptPrice = promptPrice,
|
|
nextLanePrices = lanePrices,
|
|
nextLaneEnabled = laneEnabled
|
|
) => {
|
|
const inputPrice = toNumberOrNull(nextPromptPrice)
|
|
setFormValue(
|
|
'ratio',
|
|
inputPrice !== null ? formatPricingNumber(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 (
|
|
<div
|
|
className={cn(
|
|
'bg-card flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border',
|
|
className
|
|
)}
|
|
>
|
|
<div className='border-b p-4'>
|
|
<div className='flex flex-wrap items-start justify-between gap-3'>
|
|
<div className='min-w-0'>
|
|
<h3 className='truncate text-base font-medium'>
|
|
{isEditMode ? t('Edit model pricing') : t('Add model pricing')}
|
|
</h3>
|
|
<p className='text-muted-foreground truncate text-sm'>
|
|
{activeName}
|
|
</p>
|
|
</div>
|
|
<Badge variant={getModeBadgeVariant(pricingMode)}>
|
|
{t(getModeLabel(pricingMode))}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={form.handleSubmit(handleSubmit)}
|
|
className='flex min-h-0 flex-1 flex-col'
|
|
autoComplete='off'
|
|
>
|
|
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
|
|
<FieldGroup>
|
|
{warnings.length > 0 && (
|
|
<Alert variant='destructive'>
|
|
<AlertTriangle data-icon='inline-start' />
|
|
<AlertDescription>
|
|
<div className='flex flex-col gap-1'>
|
|
{warnings.map((warning) => (
|
|
<span key={warning}>{warning}</span>
|
|
))}
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='name'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Model name')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('gpt-4')}
|
|
{...field}
|
|
disabled={isEditMode}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('The exact model identifier as used in API requests.')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<Tabs value={pricingMode} onValueChange={handleModeChange}>
|
|
<TabsList className='grid w-full grid-cols-3'>
|
|
<TabsTrigger value='per-token'>{t('Per-token')}</TabsTrigger>
|
|
<TabsTrigger value='per-request'>
|
|
{t('Per-request')}
|
|
</TabsTrigger>
|
|
<TabsTrigger value='tiered_expr'>
|
|
{t('Expression')}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value='per-token' className='flex flex-col gap-5'>
|
|
<FieldGroup>
|
|
<Field>
|
|
<FieldLabel>{t('Input price')}</FieldLabel>
|
|
<PriceInput
|
|
value={promptPrice}
|
|
placeholder='3'
|
|
onChange={handlePromptPriceChange}
|
|
/>
|
|
<FieldDescription>
|
|
{t('USD price per 1M input tokens.')}
|
|
</FieldDescription>
|
|
</Field>
|
|
|
|
<div className='grid gap-3 sm:grid-cols-2'>
|
|
{laneConfigs.map((lane) => {
|
|
const disabled =
|
|
lane.key === 'audioOutput' &&
|
|
(!laneEnabled.audioInput ||
|
|
!hasValue(lanePrices.audioInput))
|
|
return (
|
|
<PriceLane
|
|
key={lane.key}
|
|
title={t(lane.titleKey)}
|
|
description={t(lane.descriptionKey)}
|
|
placeholder={lane.placeholder}
|
|
value={lanePrices[lane.key]}
|
|
enabled={laneEnabled[lane.key]}
|
|
disabled={disabled}
|
|
onEnabledChange={(checked) =>
|
|
handleLaneToggle(lane.key, checked)
|
|
}
|
|
onChange={(value) =>
|
|
handleLanePriceChange(lane.key, value)
|
|
}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
</FieldGroup>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value='per-request'
|
|
className='flex flex-col gap-5'
|
|
>
|
|
<FormField
|
|
control={form.control}
|
|
name='price'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Fixed price')}</FormLabel>
|
|
<FormControl>
|
|
<InputGroup>
|
|
<InputGroupAddon>$</InputGroupAddon>
|
|
<InputGroupInput
|
|
inputMode='decimal'
|
|
placeholder='0.01'
|
|
{...field}
|
|
onChange={(event) => {
|
|
const value = event.target.value
|
|
if (numericDraftRegex.test(value)) {
|
|
field.onChange(value)
|
|
}
|
|
}}
|
|
/>
|
|
<InputGroupAddon align='inline-end'>
|
|
{t('per request')}
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Cost in USD per request, regardless of tokens used.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</TabsContent>
|
|
|
|
<TabsContent
|
|
value='tiered_expr'
|
|
className='flex flex-col gap-5'
|
|
>
|
|
<TieredPricingEditor
|
|
modelName={watchedValues.name}
|
|
billingExpr={billingExpr}
|
|
requestRuleExpr={requestRuleExpr}
|
|
onBillingExprChange={setBillingExpr}
|
|
onRequestRuleExprChange={setRequestRuleExpr}
|
|
/>
|
|
</TabsContent>
|
|
</Tabs>
|
|
|
|
<Collapsible open={previewOpen} onOpenChange={setPreviewOpen}>
|
|
<CollapsibleTrigger
|
|
render={
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
className='flex w-full justify-between'
|
|
/>
|
|
}
|
|
>
|
|
<span>{t('Save preview')}</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
'transition-transform',
|
|
previewOpen && 'rotate-180'
|
|
)}
|
|
/>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className='pt-3'>
|
|
<div className='rounded-lg border'>
|
|
{previewRows.map((row) => (
|
|
<div
|
|
key={row.key}
|
|
className='grid grid-cols-[140px_1fr] gap-3 border-b px-3 py-2 text-sm last:border-b-0'
|
|
>
|
|
<span className='text-muted-foreground text-xs'>
|
|
{row.label}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'min-w-0',
|
|
row.multiline
|
|
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
|
: 'truncate'
|
|
)}
|
|
>
|
|
{row.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</FieldGroup>
|
|
</div>
|
|
|
|
<SheetFooter className='bg-background/95 border-t sm:flex-row sm:items-center sm:justify-between'>
|
|
<div className='text-muted-foreground text-xs'>
|
|
{selectedTargetCount > 0
|
|
? t('{{count}} selected targets available for bulk copy.', {
|
|
count: selectedTargetCount,
|
|
})
|
|
: t('Changes are written to the settings draft on save.')}
|
|
</div>
|
|
<div className='flex justify-end gap-2'>
|
|
<Button type='button' variant='outline' onClick={onCancel}>
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button type='submit'>
|
|
{isEditMode ? t('Update') : t('Add')}
|
|
</Button>
|
|
</div>
|
|
</SheetFooter>
|
|
</form>
|
|
</Form>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PriceInput(props: {
|
|
value: string
|
|
placeholder?: string
|
|
disabled?: boolean
|
|
onChange: (value: string) => void
|
|
}) {
|
|
return (
|
|
<InputGroup>
|
|
<InputGroupAddon>$</InputGroupAddon>
|
|
<InputGroupInput
|
|
inputMode='decimal'
|
|
value={props.value}
|
|
placeholder={props.placeholder}
|
|
disabled={props.disabled}
|
|
onChange={(event) => props.onChange(event.target.value)}
|
|
/>
|
|
<InputGroupAddon align='inline-end'>$/1M</InputGroupAddon>
|
|
</InputGroup>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<SettingsControlGroup
|
|
className={cn('space-y-3', effectiveDisabled && 'opacity-75')}
|
|
data-disabled={effectiveDisabled || undefined}
|
|
>
|
|
<SettingsSwitchField
|
|
checked={props.enabled}
|
|
disabled={props.disabled}
|
|
onCheckedChange={props.onEnabledChange}
|
|
label={props.title}
|
|
description={props.description}
|
|
aria-label={props.title}
|
|
/>
|
|
<PriceInput
|
|
value={props.value}
|
|
placeholder={props.placeholder}
|
|
disabled={effectiveDisabled}
|
|
onChange={props.onChange}
|
|
/>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{props.enabled
|
|
? t('USD price per 1M tokens.')
|
|
: t('Disabled lanes are omitted on save.')}
|
|
</p>
|
|
</SettingsControlGroup>
|
|
)
|
|
}
|