CaIon 0f9f094a48
feat(default): reorganize system settings pricing UI
Refine the default system settings structure and model pricing editor so pricing configuration is easier to scan and edit.
2026-05-06 16:29:45 +08:00

1033 lines
29 KiB
TypeScript
Vendored

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,
FieldContent,
FieldDescription,
FieldGroup,
FieldLabel,
FieldTitle,
} 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 { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { combineBillingExpr } from '@/features/pricing/lib/billing-expr'
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 formatNumber(value: unknown): string {
const num = toNumberOrNull(value)
if (num === null) return ''
return Number.parseFloat(num.toFixed(12)).toString()
}
function ratioToBasePrice(ratio: unknown): string {
const num = toNumberOrNull(ratio)
if (num === null) return ''
return formatNumber(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 formatNumber(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 truncateExpr(value: string) {
if (!value) return ''
return value.length > 110 ? `${value.slice(0, 110)}...` : value
}
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 formatNumber(priceNumber / audioInputPrice)
}
const inputPrice = toNumberOrNull(nextPromptPrice)
if (inputPrice === null || inputPrice === 0) return ''
return formatNumber(priceNumber / inputPrice)
}
const syncLaneRatios = (
nextPromptPrice = promptPrice,
nextLanePrices = lanePrices,
nextLaneEnabled = laneEnabled
) => {
const inputPrice = toNumberOrNull(nextPromptPrice)
setFormValue(
'ratio',
inputPrice !== null ? formatNumber(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 (
<Field
className={cn(
'rounded-lg border p-3',
effectiveDisabled && 'bg-muted/35'
)}
data-disabled={effectiveDisabled || undefined}
>
<div className='flex items-start justify-between gap-3'>
<FieldContent>
<FieldTitle>{props.title}</FieldTitle>
<FieldDescription>{props.description}</FieldDescription>
</FieldContent>
<Switch
checked={props.enabled}
disabled={props.disabled}
onCheckedChange={props.onEnabledChange}
aria-label={props.title}
/>
</div>
<PriceInput
value={props.value}
placeholder={props.placeholder}
disabled={effectiveDisabled}
onChange={props.onChange}
/>
<FieldDescription>
{props.enabled
? t('USD price per 1M tokens.')
: t('Disabled lanes are omitted on save.')}
</FieldDescription>
</Field>
)
}