Files
chaos-api/web/default/src/features/system-settings/models/model-pricing-sheet.tsx
T
QuentinHsu 77d3157592 fix(model-pricing): commit visual pricing drafts on save
- 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
2026-06-04 17:22:50 +08:00

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>
)
}