77d3157592
- Commit the open visual editor draft before saving model pricing settings - Show unsaved draft differences against persisted model pricing values - Move model pricing actions into the editor toolbar and refine the visual editor layout
1001 lines
29 KiB
TypeScript
Vendored
1001 lines
29 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 {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useState,
|
|
} from 'react'
|
|
import * as z from 'zod'
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { AlertTriangle } from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { cn } from '@/lib/utils'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
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,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet'
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
|
import { sideDrawerContentClassName } from '@/components/drawer-layout'
|
|
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
|
|
editData?: ModelRatioData | null
|
|
}
|
|
|
|
type ModelPricingEditorPanelProps = Omit<
|
|
ModelPricingSheetProps,
|
|
'open' | 'onOpenChange'
|
|
> & {
|
|
className?: string
|
|
}
|
|
|
|
export type ModelPricingEditorPanelHandle = {
|
|
commitDraft: () => Promise<ModelRatioData | null>
|
|
}
|
|
|
|
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 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 const ModelPricingSheet = forwardRef<
|
|
ModelPricingEditorPanelHandle,
|
|
ModelPricingSheetProps
|
|
>(function ModelPricingSheet({ open, onOpenChange, editData }, ref) {
|
|
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={sideDrawerContentClassName('sm:max-w-2xl')}
|
|
>
|
|
<SheetHeader className='sr-only'>
|
|
<SheetTitle>{title}</SheetTitle>
|
|
<SheetDescription>{description}</SheetDescription>
|
|
</SheetHeader>
|
|
<ModelPricingEditorPanel
|
|
ref={ref}
|
|
editData={editData}
|
|
className='h-full rounded-none border-0'
|
|
/>
|
|
</SheetContent>
|
|
</Sheet>
|
|
)
|
|
})
|
|
|
|
export const ModelPricingEditorPanel = forwardRef<
|
|
ModelPricingEditorPanelHandle,
|
|
ModelPricingEditorPanelProps
|
|
>(function ModelPricingEditorPanel({ editData, className }, ref) {
|
|
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 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)
|
|
}, [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 validatePricingValues = useCallback(() => {
|
|
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 false
|
|
}
|
|
|
|
if (
|
|
pricingMode === 'per-token' &&
|
|
laneEnabled.audioOutput &&
|
|
!hasValue(lanePrices.audioInput)
|
|
) {
|
|
form.setError('audioRatio', {
|
|
message: t('Audio output price requires an audio input price.'),
|
|
})
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}, [form, laneEnabled, lanePrices, pricingMode, promptPrice, t])
|
|
|
|
const buildSubmitData = useCallback(
|
|
(values: ModelPricingFormValues) => {
|
|
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
|
|
}
|
|
|
|
return data
|
|
},
|
|
[billingExpr, pricingMode, requestRuleExpr]
|
|
)
|
|
|
|
useImperativeHandle(
|
|
ref,
|
|
() => ({
|
|
commitDraft: async () => {
|
|
const isValid = await form.trigger()
|
|
if (!isValid || !validatePricingValues()) return null
|
|
return buildSubmitData(form.getValues())
|
|
},
|
|
}),
|
|
[form, validatePricingValues, buildSubmitData]
|
|
)
|
|
|
|
const activeName = watchedValues.name || editData?.name || t('New model')
|
|
|
|
return (
|
|
<div
|
|
className={cn(
|
|
'bg-background 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>
|
|
</div>
|
|
</div>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
onSubmit={(event) => event.preventDefault()}
|
|
className='flex min-h-0 flex-1 flex-col'
|
|
autoComplete='off'
|
|
>
|
|
<div className='min-h-0 flex-1 overflow-y-auto p-4'>
|
|
<div className='grid items-start gap-4 xl:grid-cols-[minmax(0,1fr)_minmax(220px,260px)]'>
|
|
<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}
|
|
className='gap-4'
|
|
>
|
|
<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='pt-0'>
|
|
<FieldGroup className='gap-5'>
|
|
<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-[repeat(auto-fit,minmax(400px,1fr))]'>
|
|
{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='pt-0'>
|
|
<FieldGroup className='gap-5'>
|
|
<FormField
|
|
control={form.control}
|
|
name='price'
|
|
render={({ field }) => (
|
|
<FormItem className='contents'>
|
|
<Field>
|
|
<FieldLabel>{t('Fixed price')}</FieldLabel>
|
|
<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>
|
|
<FieldDescription>
|
|
{t(
|
|
'Cost in USD per request, regardless of tokens used.'
|
|
)}
|
|
</FieldDescription>
|
|
<FormMessage />
|
|
</Field>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</FieldGroup>
|
|
</TabsContent>
|
|
|
|
<TabsContent value='tiered_expr' className='pt-0'>
|
|
<FieldGroup className='gap-5'>
|
|
<TieredPricingEditor
|
|
modelName={watchedValues.name}
|
|
billingExpr={billingExpr}
|
|
requestRuleExpr={requestRuleExpr}
|
|
onBillingExprChange={setBillingExpr}
|
|
onRequestRuleExprChange={setRequestRuleExpr}
|
|
/>
|
|
</FieldGroup>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</FieldGroup>
|
|
|
|
<aside className='bg-muted/20 sticky top-0 rounded-lg border'>
|
|
<div className='border-b px-3 py-2'>
|
|
<div className='text-sm font-medium'>{t('Preview')}</div>
|
|
<div className='text-muted-foreground text-xs'>
|
|
{activeName}
|
|
</div>
|
|
</div>
|
|
<div className='divide-y'>
|
|
{previewRows.map((row) => (
|
|
<div key={row.key} className='grid gap-1 px-3 py-2.5'>
|
|
<span className='text-muted-foreground text-xs'>
|
|
{row.label}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
'min-w-0 text-sm',
|
|
row.multiline
|
|
? 'font-mono text-xs leading-5 break-words whitespace-pre-wrap'
|
|
: 'truncate'
|
|
)}
|
|
>
|
|
{row.value}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
)
|
|
}
|